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 yourScarb.toml
using the appropriate version.[dev-dependencies] snforge_std = "0.33.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 {
#[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 HelloStarknet
contract and call some functions.
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use testing_smart_contracts::simple_contract::{
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
'scalldata
argument) need to be serialized withSerde
.
HelloStarknet
contract has no constructor, so the calldata remains empty in the example above.
$ snforge test
Output:
Collected 1 test(s) from testing_smart_contracts package
Running 0 test(s) from src/
Running 1 test(s) from tests/
[PASS] tests::call_and_invoke
Tests: 1 passed, 0 failed, 0 skipped, 0 ignored, 0 filtered out
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::{declare, ContractClassTrait, DeclareResultTrait};
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 1 test(s) from testing_smart_contracts package
Running 0 test(s) from src/
Running 1 test(s) from tests/
[FAIL] tests::failing
Failure data:
(0x50414e4943 ('PANIC'), 0x444159544148 ('DAYTAH'))
Tests: 0 passed, 1 failed, 0 skipped, 0 ignored, 0 filtered out
Failures:
tests::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, which are not allowed in contracts themselves,
but are available for testing purposes.
They allow using the contract without automatically unwrapping the result, which allows to catch the error like shown below.
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use testing_smart_contracts::handling_errors::{
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 package_name package
Running 0 test(s) from src/
Running 1 test(s) from tests/
[PASS] tests::handling_errors
Tests: 1 passed, 0 failed, 0 skipped, 0 ignored, 0 filtered out
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::{declare, ContractClassTrait, DeclareResultTrait};
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.