Contract Testing
Testing plays a crucial role in software development, especially for smart contracts. In this section, we'll guide you through the basics of testing a smart contract on Starknet with scarb
.
Let's start with a simple smart contract as an example:
#[starknet::interface]
pub trait ISimpleContract<TContractState> {
fn get_value(self: @TContractState) -> u32;
fn get_owner(self: @TContractState) -> starknet::ContractAddress;
fn set_value(ref self: TContractState, value: u32);
}
#[starknet::contract]
pub mod SimpleContract {
use starknet::{get_caller_address, ContractAddress};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
pub value: u32,
pub owner: ContractAddress
}
#[constructor]
pub fn constructor(ref self: ContractState, initial_value: u32) {
self.value.write(initial_value);
self.owner.write(get_caller_address());
}
#[abi(embed_v0)]
pub impl SimpleContractImpl of super::ISimpleContract<ContractState> {
fn get_value(self: @ContractState) -> u32 {
self.value.read()
}
fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
fn set_value(ref self: ContractState, value: u32) {
assert(self.owner.read() == get_caller_address(), 'Not owner');
self.value.write(value);
}
}
}
Now, take a look at the tests for this contract:
#[cfg(test)]
mod tests {
// Import the interface and dispatcher to be able to interact with the contract.
use super::{SimpleContract, ISimpleContractDispatcher, ISimpleContractDispatcherTrait};
// Import the deploy syscall to be able to deploy the contract.
use starknet::{SyscallResultTrait, syscalls::deploy_syscall};
use starknet::{get_contract_address, contract_address_const};
// Use starknet test utils to fake the contract_address
use starknet::testing::set_contract_address;
// Deploy the contract and return its dispatcher.
fn deploy(initial_value: u32) -> ISimpleContractDispatcher {
// Declare and deploy
let (contract_address, _) = deploy_syscall(
SimpleContract::TEST_CLASS_HASH.try_into().unwrap(),
0,
array![initial_value.into()].span(),
false
)
.unwrap_syscall();
// Return the dispatcher.
// The dispatcher allows to interact with the contract based on its interface.
ISimpleContractDispatcher { contract_address }
}
#[test]
fn test_deploy() {
let initial_value: u32 = 10;
let contract = deploy(initial_value);
assert_eq!(contract.get_value(), initial_value);
assert_eq!(contract.get_owner(), get_contract_address());
}
#[test]
fn test_set_as_owner() {
// Fake the contract address to owner
let owner = contract_address_const::<'owner'>();
set_contract_address(owner);
// When deploying the contract, the owner is the caller.
let contract = deploy(10);
assert_eq!(contract.get_owner(), owner);
// As the current caller is the owner, the value can be set.
let new_value: u32 = 20;
contract.set_value(new_value);
assert_eq!(contract.get_value(), new_value);
}
#[test]
#[should_panic]
fn test_set_not_owner() {
let owner = contract_address_const::<'owner'>();
set_contract_address(owner);
let contract = deploy(10);
// Fake the contract address to another address
let not_owner = contract_address_const::<'not owner'>();
set_contract_address(not_owner);
// As the current caller is not the owner, the value cannot be set.
let new_value: u32 = 20;
contract.set_value(new_value);
// Panic expected
}
#[test]
#[available_gas(150000)]
fn test_deploy_gas() {
deploy(10);
}
}
To define our test, we use scarb, which allows us to create a separate module guarded with #[cfg(test)]
. This ensures that the test module is only compiled when running tests using scarb test
.
Each test is defined as a function with the #[test]
attribute. You can also check if a test panics using the #[should_panic]
attribute.
As we are in the context of a smart contract, you can also set up the gas limit for a test by using the #[available_gas(X)]
. This is a great way to ensure that some of your contract's features stay under a certain gas limit!
Note: The term "gas" here refers to Sierra gas, not L1 gas.
Now, let's move on to the testing process:
- Use the
deploy
function logic to declare and deploy your contract - Use
assert
to verify that the contract behaves as expected in the given context- You can also use assertion macros:
assert_eq!
,assert_ne!
,assert_gt!
,assert_ge!
,assert_lt!
,assert_le!
- You can also use assertion macros:
If you haven't noticed yet, every example in this book has hidden tests, you can see them by clicking the "Show hidden lines" (eyes icon) on the top right of code blocks. You can also find a detailed explanation of testing in Cairo in The Cairo Book.
Using the contract state
You can use the Contract::contract_state_for_testing
function to access the contract state. This function is only available in the test environment and allows you to mutate and read the contract state directly.
This can be useful for testing internal functions, or specific state mutations that are not exposed to the contract's interface. You can either use it with a deployed contract or as a standalone state.
Here is an example of how to do the same previous test using the contract state:
#[cfg(test)]
mod tests_with_states {
// Only import the contract and implementation
use super::SimpleContract;
use SimpleContract::SimpleContractImpl;
use starknet::contract_address_const;
use starknet::testing::set_caller_address;
use core::num::traits::Zero;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[test]
fn test_standalone_state() {
let mut state = SimpleContract::contract_state_for_testing();
// As no contract was deployed, the constructor was not called on the state
// - with valueContractMemberStateTrait
assert_eq!(state.value.read(), 0);
// - with SimpleContractImpl
assert_eq!(state.get_value(), 0);
assert_eq!(state.owner.read(), Zero::zero());
// We can still directly call the constructor to initialize the state.
let owner = contract_address_const::<'owner'>();
// We are not setting the contract address but the caller address here,
// as we are not deploying the contract but directly calling the constructor function.
set_caller_address(owner);
let initial_value: u32 = 10;
SimpleContract::constructor(ref state, initial_value);
assert_eq!(state.get_value(), initial_value);
assert_eq!(state.get_owner(), owner);
// As the current caller is the owner, the value can be set.
let new_value: u32 = 20;
state.set_value(new_value);
assert_eq!(state.get_value(), new_value);
}
// But we can also deploy the contract and interact with it using the dispatcher
// as shown in the previous tests, and still use the state for testing.
use super::{ISimpleContractDispatcher, ISimpleContractDispatcherTrait};
use starknet::{SyscallResultTrait, syscalls::deploy_syscall, testing::set_contract_address};
#[test]
fn test_state_with_contract() {
let owner = contract_address_const::<'owner'>();
let not_owner = contract_address_const::<'not owner'>();
// Deploy as owner
let initial_value: u32 = 10;
set_contract_address(owner);
let (contract_address, _) = deploy_syscall(
SimpleContract::TEST_CLASS_HASH.try_into().unwrap(),
0,
array![initial_value.into()].span(),
false
)
.unwrap_syscall();
let mut contract = ISimpleContractDispatcher { contract_address };
// create the state
// - Set back as not owner
set_contract_address(not_owner);
let mut state = SimpleContract::contract_state_for_testing();
// - Currently, the state is not 'linked' to the contract
assert_ne!(state.get_value(), initial_value);
assert_ne!(state.get_owner(), owner);
// - Link the state to the contract by setting the contract address
set_contract_address(contract.contract_address);
assert_eq!(state.get_value(), initial_value);
assert_eq!(state.get_owner(), owner);
// Mutating the state from the contract changes the testing state
set_contract_address(owner);
let new_value: u32 = 20;
contract.set_value(new_value);
set_contract_address(contract.contract_address);
assert_eq!(state.get_value(), new_value);
// Mutating the state from the testing state changes the contract state
set_caller_address(owner);
state.set_value(initial_value);
assert_eq!(contract.get_value(), initial_value);
// Directly mutating the state allows to change state
// in ways that are not allowed by the contract, such as changing the owner.
let new_owner = contract_address_const::<'new owner'>();
state.owner.write(new_owner);
assert_eq!(contract.get_owner(), new_owner);
set_caller_address(new_owner);
state.set_value(new_value);
assert_eq!(contract.get_value(), new_value);
}
}
Testing events
In order to test events, you need to use the starknet::pop_log
function. If the contract did not emit any events, the function will return Option::None
.
See the test for the Events section:
#[starknet::interface]
pub trait IEventCounter<TContractState> {
fn increment(ref self: TContractState, amount: u128);
}
#[starknet::contract]
pub mod EventCounter {
use starknet::{get_caller_address, ContractAddress};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
// Counter value
pub counter: u128,
}
#[event]
#[derive(Copy, Drop, Debug, PartialEq, starknet::Event)]
// The event enum must be annotated with the `#[event]` attribute.
// It must also derive at least the `Drop` and `starknet::Event` traits.
pub enum Event {
CounterIncreased: CounterIncreased,
UserIncreaseCounter: UserIncreaseCounter
}
// By deriving the `starknet::Event` trait, we indicate to the compiler that
// this struct will be used when emitting events.
#[derive(Copy, Drop, Debug, PartialEq, starknet::Event)]
pub struct CounterIncreased {
pub amount: u128
}
#[derive(Copy, Drop, Debug, PartialEq, starknet::Event)]
pub struct UserIncreaseCounter {
// The `#[key]` attribute indicates that this event will be indexed.
// You can also use `#[flat]` for nested structs.
#[key]
pub user: ContractAddress,
pub new_value: u128,
}
#[abi(embed_v0)]
impl EventCounter of super::IEventCounter<ContractState> {
fn increment(ref self: ContractState, amount: u128) {
self.counter.write(self.counter.read() + amount);
// Emit event
self.emit(Event::CounterIncreased(CounterIncreased { amount }));
self
.emit(
Event::UserIncreaseCounter(
UserIncreaseCounter {
user: get_caller_address(), new_value: self.counter.read()
}
)
);
}
}
}
#[cfg(test)]
mod tests {
use super::{
EventCounter, EventCounter::{Event, CounterIncreased, UserIncreaseCounter},
IEventCounterDispatcherTrait, IEventCounterDispatcher
};
use starknet::{contract_address_const, SyscallResultTrait, syscalls::deploy_syscall};
use starknet::testing::set_contract_address;
use starknet::storage::StoragePointerReadAccess;
#[test]
fn test_increment_events() {
let (contract_address, _) = deploy_syscall(
EventCounter::TEST_CLASS_HASH.try_into().unwrap(), 0, array![].span(), false
)
.unwrap_syscall();
let mut contract = IEventCounterDispatcher { contract_address };
let state = @EventCounter::contract_state_for_testing();
let amount = 10;
let caller = contract_address_const::<'caller'>();
// fake caller
set_contract_address(caller);
contract.increment(amount);
// set back to the contract for reading state
set_contract_address(contract_address);
assert_eq!(state.counter.read(), amount);
// Notice the order: the first event emitted is the first to be popped.
assert_eq!(
starknet::testing::pop_log(contract_address),
Option::Some(Event::CounterIncreased(CounterIncreased { amount }))
);
assert_eq!(
starknet::testing::pop_log(contract_address),
Option::Some(
Event::UserIncreaseCounter(UserIncreaseCounter { user: caller, new_value: amount })
)
);
}
}
Starknet Corelib Testing Module
To make testing more convenient, the testing
module of the corelib provides some helpful functions:
set_caller_address(address: ContractAddress)
set_contract_address(address: ContractAddress)
set_block_number(block_number: u64)
set_block_timestamp(block_timestamp: u64)
set_account_contract_address(address: ContractAddress)
set_sequencer_address(address: ContractAddress)
set_version(version: felt252)
set_transaction_hash(hash: felt252)
set_chain_id(chain_id: felt252)
set_nonce(nonce: felt252)
set_signature(signature: felt252)
set_max_fee(fee: u128)
pop_log_raw(address: ContractAddress) -> Option<(Span<felt252>, Span<felt252>)>
pop_log<T, +starknet::Event<T>>(address: ContractAddress) -> Option<T>
pop_l2_to_l1_message(address: ContractAddress) -> Option<(felt252, Span<felt252>)>
You may also need the info
module from the corelib, which allows you to access information about the current execution context (see syscalls):
get_caller_address() -> ContractAddress
get_contract_address() -> ContractAddress
get_block_info() -> Box<BlockInfo>
get_tx_info() -> Box<TxInfo>
get_block_timestamp() -> u64
get_block_number() -> u64
You can find the full list of functions in the Starknet Corelib repo.
Starknet Foundry
Starknet Foundry is a powerful toolkit for developing smart contracts on Starknet. It offers support for testing Starknet smart contracts on top of scarb
with the Forge
tool.
Testing with snforge
is similar to the process we just described, but simplified. Moreover, additional features are on the way, including cheatcodes and parallel test execution. We highly recommend exploring Starknet Foundry and incorporating it into your projects.
For more detailed information about testing contracts with Starknet Foundry, check out the Starknet Foundry Book - Testing Contracts.