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};

    #[storage]
    struct Storage {
        value: u32,
        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::{
        ContractAddress, get_caller_address, 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 assertions macros: assert_eq, assert_ne, assert_gt, assert_ge, assert_lt, assert_le

If you didn't noticed yet, every examples in this book have hidden tests, you can see them by clicking on 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 - Chapter 10.

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
    use super::SimpleContract;

    // For accessing storage variables and entrypoints,
    // we must import the contract member state traits and implementation.
    use SimpleContract::{
        SimpleContractImpl, valueContractMemberStateTrait, ownerContractMemberStateTrait
    };

    use starknet::contract_address_const;
    use starknet::testing::set_caller_address;
    use core::num::traits::Zero;

    #[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::{
        ContractAddress, 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 change 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 change 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};

    #[storage]
    struct Storage {
        // Counter value
        counter: u128,
    }

    #[event]
    #[derive(Copy, Drop, Debug, PartialEq, starknet::Event)]
    // The event enum must be annotated with the `#[event]` attribute.
    // It must also derive atleast `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::{
            counterContractMemberStateTrait, Event, CounterIncreased, UserIncreaseCounter
        },
        IEventCounterDispatcherTrait, IEventCounterDispatcher
    };
    use starknet::{
        ContractAddress, contract_address_const, SyscallResultTrait, syscalls::deploy_syscall
    };
    use starknet::testing::{set_contract_address, set_account_contract_address};

    #[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 found 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 or parallel tests 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.

Last change: 2024-05-03, commit: 5fcef3e