Unit Testing

You've written a smart contract. How do you know it works?

The most direct way is to test individual functions in isolation. That's what unit testing is: you pick a function, give it some inputs, and check that it does what you expect. If your transfer function should move tokens between accounts, you write a test that calls it and verifies the balances changed correctly.

In this section, we'll explore two approaches Cairo gives us for unit testing, when to use each, and the tools that make testing practical.

Two Ways to Test a Contract

Cairo offers two approaches to unit testing:

ApproachWhat You're TestingWhen to Use It
contract_state_for_testingInternal functionsLogic not in the ABI
Deploy + dispatcherExternal interface (ABI)What users actually call

Both test a single contract in isolation. The difference is whether you're testing through the public interface or reaching into the internals.

Testing Internal Functions

Sometimes the function you want to test isn't in the contract's public interface. Maybe it's a helper function in a private impl block, or it uses #[generate_trait]. You could test it indirectly through public functions, but that makes tests harder to write and failures harder to diagnose.

Cairo's contract_state_for_testing function gives you direct access. It creates a ContractState without actually deploying anything, so you can call internal functions and inspect storage.

Example: Testing a Private Setter

Let's say we have a PizzaFactory contract with an internal set_owner function that we don't expose publicly:

use starknet::ContractAddress;

#[starknet::interface]
pub trait IPizzaFactory<TContractState> {
    fn increase_pepperoni(ref self: TContractState, amount: u32);
    fn increase_pineapple(ref self: TContractState, amount: u32);
    fn get_owner(self: @TContractState) -> ContractAddress;
    fn change_owner(ref self: TContractState, new_owner: ContractAddress);
    fn make_pizza(ref self: TContractState);
    fn count_pizza(self: @TContractState) -> u32;
}

#[starknet::contract]
pub mod PizzaFactory {
    use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
    use starknet::{ContractAddress, get_caller_address};
    use super::IPizzaFactory;

    #[storage]
    pub struct Storage {
        pepperoni: u32,
        pineapple: u32,
        pub owner: ContractAddress,
        pizzas: u32,
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress) {
        self.pepperoni.write(10);
        self.pineapple.write(10);
        self.owner.write(owner);
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    pub enum Event {
        PizzaEmission: PizzaEmission,
    }

    #[derive(Drop, starknet::Event)]
    pub struct PizzaEmission {
        pub counter: u32,
    }

    #[abi(embed_v0)]
    impl PizzaFactoryimpl of super::IPizzaFactory<ContractState> {
        fn increase_pepperoni(ref self: ContractState, amount: u32) {
            assert!(amount != 0, "Amount cannot be 0");
            self.pepperoni.write(self.pepperoni.read() + amount);
        }

        fn increase_pineapple(ref self: ContractState, amount: u32) {
            assert!(amount != 0, "Amount cannot be 0");
            self.pineapple.write(self.pineapple.read() + amount);
        }

        fn make_pizza(ref self: ContractState) {
            assert!(self.pepperoni.read() > 0, "Not enough pepperoni");
            assert!(self.pineapple.read() > 0, "Not enough pineapple");

            let caller: ContractAddress = get_caller_address();
            let owner: ContractAddress = self.get_owner();

            assert!(caller == owner, "Only the owner can make pizza");

            self.pepperoni.write(self.pepperoni.read() - 1);
            self.pineapple.write(self.pineapple.read() - 1);
            self.pizzas.write(self.pizzas.read() + 1);

            self.emit(PizzaEmission { counter: self.pizzas.read() });
        }

        fn get_owner(self: @ContractState) -> ContractAddress {
            self.owner.read()
        }

        fn change_owner(ref self: ContractState, new_owner: ContractAddress) {
            self.set_owner(new_owner);
        }

        fn count_pizza(self: @ContractState) -> u32 {
            self.pizzas.read()
        }
    }

    #[generate_trait]
    pub impl InternalImpl of InternalTrait {
        fn set_owner(ref self: ContractState, new_owner: ContractAddress) {
            let caller: ContractAddress = get_caller_address();
            assert!(caller == self.get_owner(), "Only the owner can set ownership");

            self.owner.write(new_owner);
        }
    }
}

18-1: A PizzaFactory contract with internal functions

The InternalTrait contains set_owner, which isn't part of the ABI. To test it, we import the trait and call it directly on a test state:

use snforge_std::{
    ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, load, spy_events,
    start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, contract_address_const};
use crate::pizza::PizzaFactory::{Event as PizzaEvents, InternalTrait, PizzaEmission};
use crate::pizza::{IPizzaFactoryDispatcher, IPizzaFactoryDispatcherTrait, PizzaFactory};

fn owner() -> ContractAddress {
    contract_address_const::<'owner'>()
}

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap().contract_class();

    let owner: ContractAddress = contract_address_const::<'owner'>();
    let constructor_calldata = array![owner.into()];

    let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();

    let dispatcher = IPizzaFactoryDispatcher { contract_address };

    (dispatcher, contract_address)
}

#[test]
fn test_constructor() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let pepperoni_count = load(pizza_factory_address, selector!("pepperoni"), 1);
    let pineapple_count = load(pizza_factory_address, selector!("pineapple"), 1);
    assert_eq!(pepperoni_count, array![10]);
    assert_eq!(pineapple_count, array![10]);
    assert_eq!(pizza_factory.get_owner(), owner());
}

#[test]
fn test_change_owner_should_change_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let new_owner: ContractAddress = contract_address_const::<'new_owner'>();
    assert_eq!(pizza_factory.get_owner(), owner());

    start_cheat_caller_address(pizza_factory_address, owner());

    pizza_factory.change_owner(new_owner);

    assert_eq!(pizza_factory.get_owner(), new_owner);
}

#[test]
#[should_panic(expected: "Only the owner can set ownership")]
fn test_change_owner_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);
    pizza_factory.change_owner(not_owner);
    stop_cheat_caller_address(pizza_factory_address);
}

#[test]
#[should_panic(expected: "Only the owner can make pizza")]
fn test_make_pizza_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);

    pizza_factory.make_pizza();
}

#[test]
fn test_make_pizza_should_increment_pizza_counter() {
    // Setup
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    start_cheat_caller_address(pizza_factory_address, owner());
    let mut spy = spy_events();

    // When
    pizza_factory.make_pizza();

    // Then
    let expected_event = PizzaEvents::PizzaEmission(PizzaEmission { counter: 1 });
    assert_eq!(pizza_factory.count_pizza(), 1);
    spy.assert_emitted(@array![(pizza_factory_address, expected_event)]);
}

#[test]
fn test_storage_direct_access() {
    // Get a ContractState instance without deploying the contract
    let mut state = PizzaFactory::contract_state_for_testing();

    // Access storage directly - read initial value (default is 0)
    assert_eq!(state.owner.read(), contract_address_const::<0>());

    // Call internal function directly
    let owner: ContractAddress = contract_address_const::<'owner'>();
    // Note: set_owner checks caller == current owner. Since both are zero address
    // initially (no deployment context), this check passes.
    state.set_owner(owner);

    // Verify storage was updated
    assert_eq!(state.owner.read(), owner);
}


Listing 18-2: Testing internal functions with contract_state_for_testing

We import InternalTrait to access set_owner and StoragePointerReadAccess to read storage. Since there's no deployment, the test runs instantly.

Testing the External Interface

Most of your tests will deploy the contract and interact through its public interface. This is closer to how users will actually interact with your contract, which means these tests catch real-world bugs.

The Deploy-and-Test Pattern

The pattern is straightforward: declare the contract class, deploy it with constructor arguments, and interact through a dispatcher.

use snforge_std::{
    ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, load, spy_events,
    start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, contract_address_const};
use crate::pizza::PizzaFactory::{Event as PizzaEvents, InternalTrait, PizzaEmission};
use crate::pizza::{IPizzaFactoryDispatcher, IPizzaFactoryDispatcherTrait, PizzaFactory};

fn owner() -> ContractAddress {
    contract_address_const::<'owner'>()
}

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap().contract_class();

    let owner: ContractAddress = contract_address_const::<'owner'>();
    let constructor_calldata = array![owner.into()];

    let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();

    let dispatcher = IPizzaFactoryDispatcher { contract_address };

    (dispatcher, contract_address)
}

#[test]
fn test_constructor() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let pepperoni_count = load(pizza_factory_address, selector!("pepperoni"), 1);
    let pineapple_count = load(pizza_factory_address, selector!("pineapple"), 1);
    assert_eq!(pepperoni_count, array![10]);
    assert_eq!(pineapple_count, array![10]);
    assert_eq!(pizza_factory.get_owner(), owner());
}

#[test]
fn test_change_owner_should_change_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let new_owner: ContractAddress = contract_address_const::<'new_owner'>();
    assert_eq!(pizza_factory.get_owner(), owner());

    start_cheat_caller_address(pizza_factory_address, owner());

    pizza_factory.change_owner(new_owner);

    assert_eq!(pizza_factory.get_owner(), new_owner);
}

#[test]
#[should_panic(expected: "Only the owner can set ownership")]
fn test_change_owner_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);
    pizza_factory.change_owner(not_owner);
    stop_cheat_caller_address(pizza_factory_address);
}

#[test]
#[should_panic(expected: "Only the owner can make pizza")]
fn test_make_pizza_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);

    pizza_factory.make_pizza();
}

#[test]
fn test_make_pizza_should_increment_pizza_counter() {
    // Setup
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    start_cheat_caller_address(pizza_factory_address, owner());
    let mut spy = spy_events();

    // When
    pizza_factory.make_pizza();

    // Then
    let expected_event = PizzaEvents::PizzaEmission(PizzaEmission { counter: 1 });
    assert_eq!(pizza_factory.count_pizza(), 1);
    spy.assert_emitted(@array![(pizza_factory_address, expected_event)]);
}

#[test]
fn test_storage_direct_access() {
    // Get a ContractState instance without deploying the contract
    let mut state = PizzaFactory::contract_state_for_testing();

    // Access storage directly - read initial value (default is 0)
    assert_eq!(state.owner.read(), contract_address_const::<0>());

    // Call internal function directly
    let owner: ContractAddress = contract_address_const::<'owner'>();
    // Note: set_owner checks caller == current owner. Since both are zero address
    // initially (no deployment context), this check passes.
    state.set_owner(owner);

    // Verify storage was updated
    assert_eq!(state.owner.read(), owner);
}


Listing 18-3: Deploying a contract for unit testing

This helper function handles the boilerplate: declaring, preparing calldata, deploying, and returning a dispatcher you can use in tests.

Testing with Cheatcodes

Here's where things get interesting. Real smart contracts don't run in a vacuum. They check who's calling them (get_caller_address), what time it is (get_block_timestamp), and other context that's hard to control in tests.

Starknet Foundry provides cheatcodes that let you manipulate this context. Think of them as ways to set up the exact scenario you want to test.

Mocking the Caller

Access control is everywhere in smart contracts. An only_owner function needs to know who's calling it. In tests, you control this with start_cheat_caller_address:

use snforge_std::{
    ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, load, spy_events,
    start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, contract_address_const};
use crate::pizza::PizzaFactory::{Event as PizzaEvents, InternalTrait, PizzaEmission};
use crate::pizza::{IPizzaFactoryDispatcher, IPizzaFactoryDispatcherTrait, PizzaFactory};

fn owner() -> ContractAddress {
    contract_address_const::<'owner'>()
}

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap().contract_class();

    let owner: ContractAddress = contract_address_const::<'owner'>();
    let constructor_calldata = array![owner.into()];

    let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();

    let dispatcher = IPizzaFactoryDispatcher { contract_address };

    (dispatcher, contract_address)
}

#[test]
fn test_constructor() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let pepperoni_count = load(pizza_factory_address, selector!("pepperoni"), 1);
    let pineapple_count = load(pizza_factory_address, selector!("pineapple"), 1);
    assert_eq!(pepperoni_count, array![10]);
    assert_eq!(pineapple_count, array![10]);
    assert_eq!(pizza_factory.get_owner(), owner());
}

#[test]
fn test_change_owner_should_change_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let new_owner: ContractAddress = contract_address_const::<'new_owner'>();
    assert_eq!(pizza_factory.get_owner(), owner());

    start_cheat_caller_address(pizza_factory_address, owner());

    pizza_factory.change_owner(new_owner);

    assert_eq!(pizza_factory.get_owner(), new_owner);
}

#[test]
#[should_panic(expected: "Only the owner can set ownership")]
fn test_change_owner_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);
    pizza_factory.change_owner(not_owner);
    stop_cheat_caller_address(pizza_factory_address);
}

#[test]
#[should_panic(expected: "Only the owner can make pizza")]
fn test_make_pizza_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);

    pizza_factory.make_pizza();
}

#[test]
fn test_make_pizza_should_increment_pizza_counter() {
    // Setup
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    start_cheat_caller_address(pizza_factory_address, owner());
    let mut spy = spy_events();

    // When
    pizza_factory.make_pizza();

    // Then
    let expected_event = PizzaEvents::PizzaEmission(PizzaEmission { counter: 1 });
    assert_eq!(pizza_factory.count_pizza(), 1);
    spy.assert_emitted(@array![(pizza_factory_address, expected_event)]);
}

#[test]
fn test_storage_direct_access() {
    // Get a ContractState instance without deploying the contract
    let mut state = PizzaFactory::contract_state_for_testing();

    // Access storage directly - read initial value (default is 0)
    assert_eq!(state.owner.read(), contract_address_const::<0>());

    // Call internal function directly
    let owner: ContractAddress = contract_address_const::<'owner'>();
    // Note: set_owner checks caller == current owner. Since both are zero address
    // initially (no deployment context), this check passes.
    state.set_owner(owner);

    // Verify storage was updated
    assert_eq!(state.owner.read(), owner);
}


Listing 18-4: Testing access control by mocking the caller

We deploy the contract, then use start_cheat_caller_address to impersonate the owner. Now get_caller_address() returns the owner's address for all calls to this contract.

Test both paths: the happy path where an authorized caller succeeds, and the rejection path where an unauthorized caller fails. Many security bugs come from forgetting to check permissions in some code path.

Verifying Events

When your contract emits events, off-chain systems like indexers and frontends depend on them being correct. Use spy_events to capture and check them:

use snforge_std::{
    ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, load, spy_events,
    start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, contract_address_const};
use crate::pizza::PizzaFactory::{Event as PizzaEvents, InternalTrait, PizzaEmission};
use crate::pizza::{IPizzaFactoryDispatcher, IPizzaFactoryDispatcherTrait, PizzaFactory};

fn owner() -> ContractAddress {
    contract_address_const::<'owner'>()
}

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap().contract_class();

    let owner: ContractAddress = contract_address_const::<'owner'>();
    let constructor_calldata = array![owner.into()];

    let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();

    let dispatcher = IPizzaFactoryDispatcher { contract_address };

    (dispatcher, contract_address)
}

#[test]
fn test_constructor() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let pepperoni_count = load(pizza_factory_address, selector!("pepperoni"), 1);
    let pineapple_count = load(pizza_factory_address, selector!("pineapple"), 1);
    assert_eq!(pepperoni_count, array![10]);
    assert_eq!(pineapple_count, array![10]);
    assert_eq!(pizza_factory.get_owner(), owner());
}

#[test]
fn test_change_owner_should_change_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let new_owner: ContractAddress = contract_address_const::<'new_owner'>();
    assert_eq!(pizza_factory.get_owner(), owner());

    start_cheat_caller_address(pizza_factory_address, owner());

    pizza_factory.change_owner(new_owner);

    assert_eq!(pizza_factory.get_owner(), new_owner);
}

#[test]
#[should_panic(expected: "Only the owner can set ownership")]
fn test_change_owner_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);
    pizza_factory.change_owner(not_owner);
    stop_cheat_caller_address(pizza_factory_address);
}

#[test]
#[should_panic(expected: "Only the owner can make pizza")]
fn test_make_pizza_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);

    pizza_factory.make_pizza();
}

#[test]
fn test_make_pizza_should_increment_pizza_counter() {
    // Setup
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    start_cheat_caller_address(pizza_factory_address, owner());
    let mut spy = spy_events();

    // When
    pizza_factory.make_pizza();

    // Then
    let expected_event = PizzaEvents::PizzaEmission(PizzaEmission { counter: 1 });
    assert_eq!(pizza_factory.count_pizza(), 1);
    spy.assert_emitted(@array![(pizza_factory_address, expected_event)]);
}

#[test]
fn test_storage_direct_access() {
    // Get a ContractState instance without deploying the contract
    let mut state = PizzaFactory::contract_state_for_testing();

    // Access storage directly - read initial value (default is 0)
    assert_eq!(state.owner.read(), contract_address_const::<0>());

    // Call internal function directly
    let owner: ContractAddress = contract_address_const::<'owner'>();
    // Note: set_owner checks caller == current owner. Since both are zero address
    // initially (no deployment context), this check passes.
    state.set_owner(owner);

    // Verify storage was updated
    assert_eq!(state.owner.read(), owner);
}


Listing 18-5: Capturing and verifying events

Create the spy before the action that emits events, then use spy.assert_emitted to check what was emitted.

Reading Storage Directly

Sometimes you need to verify storage values that don't have public getters. The load function reads raw storage:

use snforge_std::{
    ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, load, spy_events,
    start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, contract_address_const};
use crate::pizza::PizzaFactory::{Event as PizzaEvents, InternalTrait, PizzaEmission};
use crate::pizza::{IPizzaFactoryDispatcher, IPizzaFactoryDispatcherTrait, PizzaFactory};

fn owner() -> ContractAddress {
    contract_address_const::<'owner'>()
}

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap().contract_class();

    let owner: ContractAddress = contract_address_const::<'owner'>();
    let constructor_calldata = array![owner.into()];

    let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();

    let dispatcher = IPizzaFactoryDispatcher { contract_address };

    (dispatcher, contract_address)
}

#[test]
fn test_constructor() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let pepperoni_count = load(pizza_factory_address, selector!("pepperoni"), 1);
    let pineapple_count = load(pizza_factory_address, selector!("pineapple"), 1);
    assert_eq!(pepperoni_count, array![10]);
    assert_eq!(pineapple_count, array![10]);
    assert_eq!(pizza_factory.get_owner(), owner());
}

#[test]
fn test_change_owner_should_change_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();

    let new_owner: ContractAddress = contract_address_const::<'new_owner'>();
    assert_eq!(pizza_factory.get_owner(), owner());

    start_cheat_caller_address(pizza_factory_address, owner());

    pizza_factory.change_owner(new_owner);

    assert_eq!(pizza_factory.get_owner(), new_owner);
}

#[test]
#[should_panic(expected: "Only the owner can set ownership")]
fn test_change_owner_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);
    pizza_factory.change_owner(not_owner);
    stop_cheat_caller_address(pizza_factory_address);
}

#[test]
#[should_panic(expected: "Only the owner can make pizza")]
fn test_make_pizza_should_panic_when_not_owner() {
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    let not_owner = contract_address_const::<'not_owner'>();
    start_cheat_caller_address(pizza_factory_address, not_owner);

    pizza_factory.make_pizza();
}

#[test]
fn test_make_pizza_should_increment_pizza_counter() {
    // Setup
    let (pizza_factory, pizza_factory_address) = deploy_pizza_factory();
    start_cheat_caller_address(pizza_factory_address, owner());
    let mut spy = spy_events();

    // When
    pizza_factory.make_pizza();

    // Then
    let expected_event = PizzaEvents::PizzaEmission(PizzaEmission { counter: 1 });
    assert_eq!(pizza_factory.count_pizza(), 1);
    spy.assert_emitted(@array![(pizza_factory_address, expected_event)]);
}

#[test]
fn test_storage_direct_access() {
    // Get a ContractState instance without deploying the contract
    let mut state = PizzaFactory::contract_state_for_testing();

    // Access storage directly - read initial value (default is 0)
    assert_eq!(state.owner.read(), contract_address_const::<0>());

    // Call internal function directly
    let owner: ContractAddress = contract_address_const::<'owner'>();
    // Note: set_owner checks caller == current owner. Since both are zero address
    // initially (no deployment context), this check passes.
    state.set_owner(owner);

    // Verify storage was updated
    assert_eq!(state.owner.read(), owner);
}


Listing 18-6: Reading storage directly to verify initial state

Pass the contract address, a storage key (use selector!("variable_name")), and how many felts to read.

Cheatcode Reference

Here are the cheatcodes you'll use most often:

CheatcodeWhat It DoesCommon Use Case
start_cheat_caller_addressMock get_caller_addressTesting access control
stop_cheat_caller_addressStop mocking callerReset after test
start_cheat_block_timestampMock block timestampTime-dependent logic
start_cheat_block_numberMock block numberBlock-dependent logic
spy_eventsCapture emitted eventsVerify event data
loadRead raw storageVerify internal state
storeWrite raw storageSet up test preconditions

For the full list, see the Starknet Foundry cheatcode documentation.

Choosing Your Approach

What You're TestingUse
Internal helper functioncontract_state_for_testing
Pure calculation logiccontract_state_for_testing
Access controlDeploy + cheatcodes
EventsDeploy + spy_events
Constructor logicDeploy + assertions
Public function behaviorDeploy + dispatcher

One thing to know: you can't mix approaches in the same test. If you deploy and get a dispatcher, use the dispatcher. If you use contract_state_for_testing, work with that state object directly.

Writing Good Tests

A few patterns make tests easier to write and maintain:

One behavior per test. When a test fails, you want to know exactly what broke. A test named test_transfer that checks balances, events, and error cases is hard to debug. Split it:

// Each test checks one thing
#[test]
fn test_transfer_updates_balances() { /* ... */ }

#[test]
fn test_transfer_emits_event() { /* ... */ }

// Not this - too much in one test
#[test]
fn test_transfer() {
    // Checks balances AND events AND error cases...
}

Name tests so failures tell you what's wrong. Use test_<function>_<expected_behavior>:

  • test_transfer_updates_balances
  • test_transfer_fails_on_insufficient_balance
  • test_change_owner_requires_current_owner

Clean up cheatcodes. Always pair start_cheat_* with the corresponding stop_cheat_*:

start_cheat_caller_address(address, owner());
// ... test logic ...
stop_cheat_caller_address(address);

Test edge cases. Unit tests are perfect for boundary conditions: zero values, maximum values like u256::MAX, empty arrays, and off-by-one scenarios.

When Unit Tests Aren't Enough

Unit tests verify single-contract behavior. When your contract talks to other contracts, you'll want integration testing to test those interactions.

For testing that properties hold across many random inputs, see property-based testing. And when you need to test against real deployed contracts like AMMs or lending protocols, fork testing lets you do that.

Summary

Unit testing in Cairo means testing one contract at a time. You have two approaches:

  • contract_state_for_testing for internal functions: fast, no deployment, direct access to contract state.

  • Deploy + dispatcher + cheatcodes for the public interface: tests what users actually experience, with tools to control caller identity, timestamps, and more.

Start with the public interface tests. They catch more real bugs. Reach for contract_state_for_testing when you have internal logic that's worth testing directly.