Testing Smart Contracts

Testing smart contracts is a critical part of the development process. It is important to ensure that smart contracts behave as expected and that they are secure.

In a previous section of the Cairo Book, we learned how to write and structure our tests for Cairo programs. We demonstrated how these tests could be run using the scarb command-line tool. While this approach is useful for testing standalone Cairo programs and functions, it lacks functionality for testing smart contracts that require control over the contract state and execution context. Therefore, in this section, we will introduce how to use Starknet Foundry, a smart contract development toolchain for Starknet, to test your Cairo contracts.

Throughout this chapter, we will be using as an example the PizzaFactory contract in Listing 17-1 to demonstrate how to write tests with Starknet Foundry.

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 super::IPizzaFactory;
    use starknet::ContractAddress;
    use starknet::get_caller_address;

    #[storage]
    pub struct Storage {
        pepperoni: u32,
        pineapple: u32,
        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);
        }
    }
}

Listing 17-1: A pizza factory that needs to be tested

Configuring your Scarb project with Starknet Foundry

The settings of your Scarb project can be configured in the Scarb.toml file. To use Starknet Foundry, as your testing tool, you will need to add it as a dev dependency in your Scarb.toml file. At the time of writing, the latest version of Starknet Foundry is v0.22.0 - but you should use the latest version.

[dev-dependencies]
snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.22.0" }

[scripts]
test = "snforge test"

The scarb test command is configured to execute scarb cairo-test by default. In our settings, we have configured it to execute snforge test instead. This will allow us to run our tests using Starknet Foundry when we run the scarb test command.

Once your project is configured, you will need to install Starknet Foundry by following the installation guide from the Starknet Foundry Documentation. As usual, we recommend to use asdf to manage versions of your development tools.

Testing Smart Contracts with Starknet Foundry

The usual command to run your tests using Starknet Foundry is snforge test. However, when we configured our projects, we defined that the scarb test command will run the snforge test command. Therefore, during the rest of this chapter, consider that the scarb test command will be using snforge test under the hood.

The usual testing flow of a contract is as follows:

  1. Declare the class of the contract to test, identified by its name
  2. Serialize the constructor calldata into an array
  3. Deploy the contract and retrieve its address
  4. Interact with the contract's entrypoint to test various scenarios

Deploying the Contract to Test

In Listing 17-2, we wrote a function that deploys the PizzaFactory contract and sets up the dispatcher for interactions.

use source::pizza::{
    IPizzaFactoryDispatcher, IPizzaFactorySafeDispatcher, IPizzaFactoryDispatcherTrait,
    PizzaFactory, PizzaFactory::{Event as PizzaEvents, PizzaEmission}
};
use source::pizza::PizzaFactory::{ownerContractMemberStateTrait, InternalTrait};

use starknet::{ContractAddress, contract_address_const};

use snforge_std::{
    declare, ContractClassTrait, ContractClass, start_prank, stop_prank, CheatTarget, SpyOn,
    EventSpy, EventAssertions, spy_events, EventFetcher, load, cheatcodes::storage::load_felt252
};

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

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap();
    let owner: ContractAddress = contract_address_const::<'owner'>();

    let mut 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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), not_owner);
    pizza_factory.change_owner(not_owner);
    stop_prank(CheatTarget::One(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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), owner());
    let mut spy = spy_events(SpyOn::One(pizza_factory_address));

    // 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_set_as_new_owner_direct() {
    let mut state = PizzaFactory::contract_state_for_testing();
    let owner: ContractAddress = contract_address_const::<'owner'>();
    state.set_owner(owner);
    assert_eq!(state.owner.read(), owner);
}

Listing 17-2 Deploying the contract to test

Testing our Contract

Determining the behavior that your contract should respect is the first step in writing tests. In the PizzaFactory contract, we determined that the contract should have the following behavior:

  • Upon deployment, the contract owner should be set to the address provided in the constructor, and the factory should have 10 units of pepperoni and pineapple, and no pizzas created.
  • If someone tries to make a pizza and they are not the owner, the operation should fail. Otherwise, the pizza count should be incremented, and an event should be emitted.
  • If someone tries to take ownership of the contract and they are not the owner, the operation should fail. Otherwise, the owner should be updated.

Accessing Storage Variables with load

use source::pizza::{
    IPizzaFactoryDispatcher, IPizzaFactorySafeDispatcher, IPizzaFactoryDispatcherTrait,
    PizzaFactory, PizzaFactory::{Event as PizzaEvents, PizzaEmission}
};
use source::pizza::PizzaFactory::{ownerContractMemberStateTrait, InternalTrait};

use starknet::{ContractAddress, contract_address_const};

use snforge_std::{
    declare, ContractClassTrait, ContractClass, start_prank, stop_prank, CheatTarget, SpyOn,
    EventSpy, EventAssertions, spy_events, EventFetcher, load, cheatcodes::storage::load_felt252
};

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

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap();
    let owner: ContractAddress = contract_address_const::<'owner'>();

    let mut 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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), not_owner);
    pizza_factory.change_owner(not_owner);
    stop_prank(CheatTarget::One(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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), owner());
    let mut spy = spy_events(SpyOn::One(pizza_factory_address));

    // 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_set_as_new_owner_direct() {
    let mut state = PizzaFactory::contract_state_for_testing();
    let owner: ContractAddress = contract_address_const::<'owner'>();
    state.set_owner(owner);
    assert_eq!(state.owner.read(), owner);
}

Listing 17-3: Testing the initial state by loading storage variables

Once our contract is deployed, we want to assert that the initial values are set as expected. If our contract has an entrypoint that returns the value of a storage variable, we can call this entrypoint. Otherwise, we can use the load function from snforge to load the value of a storage variable inside our contract, even if not exposed by an entrypoint.

Mocking the Caller Address with start_prank

The security of our factory relies on the owner being the only one able to make pizzas and transfer ownership. To test this, we can use the stark_prank function to mock the caller address and assert that the contract behaves as expected.

use source::pizza::{
    IPizzaFactoryDispatcher, IPizzaFactorySafeDispatcher, IPizzaFactoryDispatcherTrait,
    PizzaFactory, PizzaFactory::{Event as PizzaEvents, PizzaEmission}
};
use source::pizza::PizzaFactory::{ownerContractMemberStateTrait, InternalTrait};

use starknet::{ContractAddress, contract_address_const};

use snforge_std::{
    declare, ContractClassTrait, ContractClass, start_prank, stop_prank, CheatTarget, SpyOn,
    EventSpy, EventAssertions, spy_events, EventFetcher, load, cheatcodes::storage::load_felt252
};

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

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap();
    let owner: ContractAddress = contract_address_const::<'owner'>();

    let mut 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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), not_owner);
    pizza_factory.change_owner(not_owner);
    stop_prank(CheatTarget::One(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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), owner());
    let mut spy = spy_events(SpyOn::One(pizza_factory_address));

    // 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_set_as_new_owner_direct() {
    let mut state = PizzaFactory::contract_state_for_testing();
    let owner: ContractAddress = contract_address_const::<'owner'>();
    state.set_owner(owner);
    assert_eq!(state.owner.read(), owner);
}

Listing 17-4: Testing ownership of the contract by mocking the caller address

Using stark_prank, we call the change_owner function first as the owner, and then as a different address. We assert that the operation fails when the caller is not the owner, and that the owner is updated when the caller is the owner.

Capturing Events with spy_events

When a pizza is created, the contract emits an event. To test this, we can use the spy_events function to capture the emitted events and assert that the event was emitted with the expected parameters. Naturally, we can also assert that the pizza count was incremented, and that only the owner can make a pizza.

use source::pizza::{
    IPizzaFactoryDispatcher, IPizzaFactorySafeDispatcher, IPizzaFactoryDispatcherTrait,
    PizzaFactory, PizzaFactory::{Event as PizzaEvents, PizzaEmission}
};
use source::pizza::PizzaFactory::{ownerContractMemberStateTrait, InternalTrait};

use starknet::{ContractAddress, contract_address_const};

use snforge_std::{
    declare, ContractClassTrait, ContractClass, start_prank, stop_prank, CheatTarget, SpyOn,
    EventSpy, EventAssertions, spy_events, EventFetcher, load, cheatcodes::storage::load_felt252
};

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

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap();
    let owner: ContractAddress = contract_address_const::<'owner'>();

    let mut 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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), not_owner);
    pizza_factory.change_owner(not_owner);
    stop_prank(CheatTarget::One(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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), owner());
    let mut spy = spy_events(SpyOn::One(pizza_factory_address));

    // 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_set_as_new_owner_direct() {
    let mut state = PizzaFactory::contract_state_for_testing();
    let owner: ContractAddress = contract_address_const::<'owner'>();
    state.set_owner(owner);
    assert_eq!(state.owner.read(), owner);
}

Testing the events emitted when a pizza is created

Accessing Internal Functions with contract_state_for_testing

All the tests we have seen so far have been using a workflow that involves deploying the contract and interacting with the contract's entrypoints. However, sometimes we may want to test the internals of the contract directly, without deploying the contract. How could this be done, if we were reasoning in purely Cairo terms?

Recall the struct ContractState, which is used as a parameter to all the entrypoints of a contract. To make it short, this struct contains zero-sized fields, corresponding to the storage variables of the contract. The only purpose of these fields is to allow the Cairo compiler to generate the correct code for accessing the storage variables. If we could create an instance of this struct, we could access these storage variables directly, without deploying the contract...

...and this is exactly what the contract_state_for_testing function does! It creates an instance of the ContractState struct, allowing us to call any function that takes as parameter a ContractState struct, without deploying the contract. To interact with the storage variables properly, we need to manually import the traits that define access to the storage variables.

use source::pizza::{
    IPizzaFactoryDispatcher, IPizzaFactorySafeDispatcher, IPizzaFactoryDispatcherTrait,
    PizzaFactory, PizzaFactory::{Event as PizzaEvents, PizzaEmission}
};
use source::pizza::PizzaFactory::{ownerContractMemberStateTrait, InternalTrait};

use starknet::{ContractAddress, contract_address_const};

use snforge_std::{
    declare, ContractClassTrait, ContractClass, start_prank, stop_prank, CheatTarget, SpyOn,
    EventSpy, EventAssertions, spy_events, EventFetcher, load, cheatcodes::storage::load_felt252
};

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

fn deploy_pizza_factory() -> (IPizzaFactoryDispatcher, ContractAddress) {
    let contract = declare("PizzaFactory").unwrap();
    let owner: ContractAddress = contract_address_const::<'owner'>();

    let mut 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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), not_owner);
    pizza_factory.change_owner(not_owner);
    stop_prank(CheatTarget::One(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_prank(CheatTarget::One(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_prank(CheatTarget::One(pizza_factory_address), owner());
    let mut spy = spy_events(SpyOn::One(pizza_factory_address));

    // 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_set_as_new_owner_direct() {
    let mut state = PizzaFactory::contract_state_for_testing();
    let owner: ContractAddress = contract_address_const::<'owner'>();
    state.set_owner(owner);
    assert_eq!(state.owner.read(), owner);
}

Unit testing our contract without deployment

These imports give us access to our internal functions (notably, set_owner), as well as the read/write access to the owner storage variable. Once we have these, we can interact with the contract directly, changing the address of the owner by calling the set_owner method, accessible through InternalTrait, and reading the owner storage variable.

Note: Both approaches cannot be used at the same time. If you decide to deploy the contract, you interact with it using the dispatcher. If you decide to test the internal functions, you interact with the ContractState object directly.

$ scarb test 
     Running test source (snforge test)
   Compiling source v0.1.0 (listings/ch17-starknet-smart-contracts-security/listing_02_pizza_factory_snfoundry/Scarb.toml)
    Finished release target(s) in 1 second


Collected 6 test(s) from source package
Running 6 test(s) from src/
[PASS] source::tests::foundry_test::test_set_as_new_owner_direct (gas: ~130)
[PASS] source::tests::foundry_test::test_make_pizza_should_panic_when_not_owner (gas: ~296)
[PASS] source::tests::foundry_test::test_constructor (gas: ~296)
[PASS] source::tests::foundry_test::test_change_owner_should_panic_when_not_owner (gas: ~296)
[PASS] source::tests::foundry_test::test_make_pizza_should_increment_pizza_counter (gas: ~366)
[PASS] source::tests::foundry_test::test_change_owner_should_change_owner (gas: ~300)
Tests: 6 passed, 0 failed, 0 skipped, 0 ignored, 0 filtered out

The output of the tests shows that all the tests passed successfully, along with an estimation of the gas consumed by each test.

Summary

In this chapter, we learned how to test smart contracts using Starknet Foundry. We demonstrated how to deploy a contract and interact with it using the dispatcher. We also showed how to test the contract's behavior by mocking the caller address and capturing events. Finally, we demonstrated how to test the internal functions of the contract directly, without deploying the contract.

To learn more about Starknet Foundry, refer to the Starknet Foundry documentation