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:
| Approach | What You're Testing | When to Use It |
|---|---|---|
contract_state_for_testing | Internal functions | Logic not in the ABI |
| Deploy + dispatcher | External 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:
| Cheatcode | What It Does | Common Use Case |
|---|---|---|
start_cheat_caller_address | Mock get_caller_address | Testing access control |
stop_cheat_caller_address | Stop mocking caller | Reset after test |
start_cheat_block_timestamp | Mock block timestamp | Time-dependent logic |
start_cheat_block_number | Mock block number | Block-dependent logic |
spy_events | Capture emitted events | Verify event data |
load | Read raw storage | Verify internal state |
store | Write raw storage | Set up test preconditions |
For the full list, see the Starknet Foundry cheatcode documentation.
Choosing Your Approach
| What You're Testing | Use |
|---|---|
| Internal helper function | contract_state_for_testing |
| Pure calculation logic | contract_state_for_testing |
| Access control | Deploy + cheatcodes |
| Events | Deploy + spy_events |
| Constructor logic | Deploy + assertions |
| Public function behavior | Deploy + 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_balancestest_transfer_fails_on_insufficient_balancetest_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_testingfor 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.