Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Important: If you're upgrading snforge to version 0.48.0 or later, please read the 0.48.0 Migration Guide.

Testing Smart Contracts

â„šī¸ Info

To use the library functions designed for testing smart contracts, you need to add snforge_std package as a dependency in your Scarb.toml using the appropriate version.

[dev-dependencies]
snforge_std = "0.37.0"

Using unit testing as much as possible is a good practice, as it makes your test suites run faster. However, when writing smart contracts, you often want to test their interactions with the blockchain state and with other contracts.

The Test Contract

Let's consider a simple smart contract with two methods.

#[starknet::interface]
pub trait ISimpleContract<TContractState> {
    fn increase_balance(ref self: TContractState, amount: felt252);
    fn get_balance(self: @TContractState) -> felt252;
}

#[starknet::contract]
pub mod SimpleContract {
    use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
    #[storage]
    struct Storage {
        balance: felt252,
    }

    #[abi(embed_v0)]
    pub impl SimpleContractImpl of super::ISimpleContract<ContractState> {
        // Increases the balance by the given amount
        fn increase_balance(ref self: ContractState, amount: felt252) {
            self.balance.write(self.balance.read() + amount);
        }

        // Gets the balance.
        fn get_balance(self: @ContractState) -> felt252 {
            self.balance.read()
        }
    }
}

Note that the name after mod will be used as the contract name for testing purposes.

Writing Tests

Let's write a test that will deploy the SimpleContract contract and call some functions.

use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use testing_smart_contracts_writing_tests::{
    ISimpleContractDispatcher, ISimpleContractDispatcherTrait,
};

#[test]
fn call_and_invoke() {
    // First declare and deploy a contract
    let contract = declare("SimpleContract").unwrap().contract_class();
    // Alternatively we could use `deploy_syscall` here
    let (contract_address, _) = contract.deploy(@array![]).unwrap();

    // Create a Dispatcher object that will allow interacting with the deployed contract
    let dispatcher = ISimpleContractDispatcher { contract_address };

    // Call a view function of the contract
    let balance = dispatcher.get_balance();
    assert(balance == 0, 'balance == 0');

    // Call a function of the contract
    // Here we mutate the state of the storage
    dispatcher.increase_balance(100);

    // Check that transaction took effect
    let balance = dispatcher.get_balance();
    assert(balance == 100, 'balance == 100');
}

📝 Note

Notice that the arguments to the contract's constructor (the deploy's calldata argument) need to be serialized with Serde.

SimpleContract contract has no constructor, so the calldata remains empty in the example above.

$ snforge test
Output:
Collected 2 test(s) from testing_smart_contracts_handling_errors package
Running 2 test(s) from tests/
[FAIL] testing_smart_contracts_handling_errors_integrationtest::panic::failing

Failure data:
    (0x50414e4943 ('PANIC'), 0x444159544148 ('DAYTAH'))

[PASS] testing_smart_contracts_handling_errors_integrationtest::handle_panic::handling_string_errors (l1_gas: ~0, l1_data_gas: ~96, l2_gas: ~280000)
Running 0 test(s) from src/
Tests: 1 passed, 1 failed, 0 ignored, 0 filtered out

Failures:
    testing_smart_contracts_handling_errors_integrationtest::panic::failing

Handling Errors

Sometimes we want to test contracts functions that can panic, like testing that function that verifies caller address panics on invalid address. For that purpose Starknet also provides a SafeDispatcher, that returns a Result instead of panicking.

First, let's add a new, panicking function to our contract.

#[starknet::interface]
pub trait IPanicContract<TContractState> {
    fn do_a_panic(self: @TContractState);
    fn do_a_string_panic(self: @TContractState);
}

#[starknet::contract]
pub mod PanicContract {
    use core::array::ArrayTrait;

    #[storage]
    struct Storage {}

    #[abi(embed_v0)]
    pub impl PanicContractImpl of super::IPanicContract<ContractState> {
        // Panics
        fn do_a_panic(self: @ContractState) {
            panic(array!['PANIC', 'DAYTAH']);
        }

        fn do_a_string_panic(self: @ContractState) {
            // A macro which allows panicking with a ByteArray (string) instance
            panic!("This is panicking with a string, which can be longer than 31 characters");
        }
    }
}

If we called this function in a test, it would result in a failure.

use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use testing_smart_contracts_handling_errors::{
    IPanicContractDispatcher, IPanicContractDispatcherTrait,
};

#[test]
fn failing() {
    let contract = declare("PanicContract").unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@array![]).unwrap();
    let dispatcher = IPanicContractDispatcher { contract_address };

    dispatcher.do_a_panic();
}
$ snforge test
Output:
Collected 2 test(s) from testing_smart_contracts_handling_errors package
Running 2 test(s) from tests/
[FAIL] testing_smart_contracts_handling_errors_integrationtest::panic::failing

Failure data:
    (0x50414e4943 ('PANIC'), 0x444159544148 ('DAYTAH'))

[PASS] testing_smart_contracts_handling_errors_integrationtest::handle_panic::handling_string_errors (l1_gas: ~0, l1_data_gas: ~96, l2_gas: ~280000)
Running 0 test(s) from src/
Tests: 1 passed, 1 failed, 0 ignored, 0 filtered out

Failures:
    testing_smart_contracts_handling_errors_integrationtest::panic::failing

SafeDispatcher

Using SafeDispatcher we can test that the function in fact panics with an expected message. Safe dispatcher is a special kind of dispatcher that allows using the contract without automatically unwrapping the result, thereby making possible to catch the error like shown below.

use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use testing_smart_contracts_safe_dispatcher::{
    IPanicContractSafeDispatcher, IPanicContractSafeDispatcherTrait,
};

#[test]
#[feature("safe_dispatcher")]
fn handling_errors() {
    let contract = declare("PanicContract").unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@array![]).unwrap();
    let safe_dispatcher = IPanicContractSafeDispatcher { contract_address };

    match safe_dispatcher.do_a_panic() {
        Result::Ok(_) => panic!("Entrypoint did not panic"),
        Result::Err(panic_data) => {
            assert(*panic_data.at(0) == 'PANIC', *panic_data.at(0));
            assert(*panic_data.at(1) == 'DAYTAH', *panic_data.at(1));
        },
    };
}

Now the test passes as expected.

$ snforge test
Output:
Collected 1 test(s) from testing_smart_contracts_safe_dispatcher package
Running 0 test(s) from src/
Running 1 test(s) from tests/
[PASS] testing_smart_contracts_safe_dispatcher_integrationtest::safe_dispatcher::handling_errors (l1_gas: ~0, l1_data_gas: ~96, l2_gas: ~280000)
Tests: 1 passed, 0 failed, 0 ignored, 0 filtered out

📝 Note

It is not possible to catch errors that cause immediate termination of execution, e.g. calling a contract with a nonexistent address. A full list of such errors can be found here.

Similarly, you can handle the panics which use ByteArray as an argument (like an assert! or panic! macro)

// Necessary utility function import
use snforge_std::byte_array::try_deserialize_bytearray_error;
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use testing_smart_contracts_handling_errors::{
    IPanicContractSafeDispatcher, IPanicContractSafeDispatcherTrait,
};

#[test]
#[feature("safe_dispatcher")]
fn handling_string_errors() {
    let contract = declare("PanicContract").unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@array![]).unwrap();
    let safe_dispatcher = IPanicContractSafeDispatcher { contract_address };

    match safe_dispatcher.do_a_string_panic() {
        Result::Ok(_) => panic!("Entrypoint did not panic"),
        Result::Err(panic_data) => {
            let str_err = try_deserialize_bytearray_error(panic_data.span()).expect('wrong format');

            assert(
                str_err == "This is panicking with a string, which can be longer than 31 characters",
                'wrong string received',
            );
        },
    };
}

You also could skip the de-serialization of the panic_data, and not use try_deserialize_bytearray_error, but this way you can actually use assertions on the ByteArray that was used to panic.

📝 Note

To operate with SafeDispatcher it's required to annotate its usage with #[feature("safe_dispatcher")].

There are 3 options:

  • module-level declaration
    #[feature("safe_dispatcher")]
    mod my_module;
  • function-level declaration
    #[feature("safe_dispatcher")]
    fn my_function() { ... }
  • directly before the usage
    #[feature("safe_dispatcher")]
    let result = safe_dispatcher.some_function();

Expecting Test Failure

Sometimes the test code failing can be a desired behavior. Instead of manually handling it, you can simply mark your test as #[should_panic(...)]. See here for more details.

Passing Constructor Arguments

The previous example was a basic one. However, sometimes you may need to pass arguments to contract's constructor. This can be done in two ways:

  • With manual serialization
  • With deploy_for_test function (available since Cairo 2.12)

Let's compare both approaches.

Test Contract

Below contract simulates a basic shopping cart. Its constructor takes initial products which are vector of Product structs.

#[derive(Copy, Debug, Drop, Serde, starknet::Store)]
pub struct Product {
    pub name: felt252,
    pub price: u64,
    pub quantity: u64,
}

#[starknet::interface]
pub trait IShoppingCart<TContractState> {
    fn get_products(self: @TContractState) -> Array<Product>;
}

#[starknet::contract]
pub mod ShoppingCart {
    use starknet::storage::{MutableVecTrait, StoragePointerReadAccess, Vec, VecTrait};
    use super::Product;

    #[storage]
    struct Storage {
        products: Vec<Product>,
    }

    #[constructor]
    fn constructor(ref self: ContractState, initial_products: Array<Product>) {
        for product in initial_products {
            self.products.push(product);
        }
    }


    #[abi(embed_v0)]
    impl ShoppingCartImpl of super::IShoppingCart<ContractState> {
        fn get_products(self: @ContractState) -> Array<Product> {
            let mut products = array![];
            for i in 0..self.products.len() {
                products.append(self.products.at(i).read());
            }
            products
        }
    }
}

Deployment with deploy_for_test

deploy_for_test is an utility function that simplifies the deployment process by automatically handling serialization of constructor parameters.

use deployment_with_constructor_args::Product;
use deployment_with_constructor_args::ShoppingCart::deploy_for_test;
use snforge_std::{DeclareResult, DeclareResultTrait, declare};
use starknet::deployment::DeploymentParams;
use starknet::storage::StorableStoragePointerReadAccess;

#[test]
fn test_initial_cart_non_empty_with_deploy_for_test() {
    // 1. Declare contract
    let declare_result: DeclareResult = declare("ShoppingCart").unwrap();
    let class_hash = declare_result.contract_class().class_hash;

    // 2. Create deployment parameters
    let deployment_params = DeploymentParams { salt: 0, deploy_from_zero: true };

    // 3. Create initial products
    let initial_products = array![
        Product { name: 'Bread', price: 5, quantity: 2 },
        Product { name: 'Milk', price: 2, quantity: 4 },
        Product { name: 'Eggs', price: 3, quantity: 12 },
    ];

    // 4. Use `deploy_for_test` to deploy the contract
    // It automatically handles serialization of constructor parameters
    let (_contract_address, _) = deploy_for_test(*class_hash, deployment_params, initial_products)
        .expect('Deployment failed');
}

Deployment with Manual Serialization

In this case we need to manually serialize the constructor parameters and pass them as calldata to the deploy function.

use deployment_with_constructor_args::Product;
use snforge_std::{ContractClassTrait, DeclareResult, DeclareResultTrait, declare};
use starknet::storage::StorableStoragePointerReadAccess;


#[test]
fn test_initial_cart_non_empty_with_serialization() {
    // 1. Declare contract
    let declare_result: DeclareResult = declare("ShoppingCart").unwrap();
    let contract = declare_result.contract_class();

    // 2. Create deployment parameters
    let initial_products = array![
        Product { name: 'Bread', price: 5, quantity: 2 },
        Product { name: 'Milk', price: 2, quantity: 4 },
        Product { name: 'Eggs', price: 3, quantity: 12 },
    ];

    // 3. Create calldata
    let mut calldata = ArrayTrait::new();

    // 4. Serialize initial products
    initial_products.serialize(ref calldata);

    // 5. Deploy the contract
    let (_contract_address, _) = contract.deploy(@calldata).unwrap();
}