Effective Starknet Contract Testing with Starknet Foundry

Tomasz Donald Kumor
Software Mansion
Published in
5 min readNov 8, 2023

--

Testing is an essential component of software production. A good tool can be evaluated based on its reliability, adaptability, and ease of use. When testing Starknet smart contracts in Cairo, we have multiple options, but this article will focus on Starknet Foundry. This tool is a high speed toolkit for Starknet contracts.

Let us delve into how to set up Starknet Foundry and use it to test a voting contract.

Setting up the Environment

To work with Starknet Foundry, it’s necessary to install Scarb. It can be done via asdf:

asdf plugin add scarb
asdf install scarb latest

next step, install Starknet Foundry:

asdf plugin add starknet-foundry
asdf install starknet-foundry latest

Once that’s completed, ensure the correct setup by reloading your terminal and executing the following:

snforge --version
sncast --version

Initializing a New Project

The encouraged approach to initializing a Starknet Foundry project involves the following command:

snforge init program_name

This action creates a project directory with these contents.

├── README.md
├── Scarb.toml
├── src
└── tests

Among these, we have:

  • src: contains the source code of your contracts.
  • tests: contains your tests.
  • Scarb.toml: contains the project's configuration.

The Voting Contract

The main focus of our testing in this article is a contract that enables explicit voting procedures to be conducted. This contract adheres to certain conditions:

  • Voting options are restricted to yes or no
  • Each voter possesses a single vote
  • The vote is permanent

The contract’s code is as follows:

#[starknet::interface]
trait IVotingContract<TContractState> {
fn vote(ref self: TContractState, vote: felt252);
fn get_votes(self: @TContractState) -> (felt252, felt252);
}

#[starknet::contract]
mod VotingContract {
use starknet::get_caller_address;
use starknet::ContractAddress;
use traits::Into;

#[storage]
struct Storage {
yes_votes: felt252,
no_votes: felt252,
voters: LegacyMap::<ContractAddress, bool>,
}

#[external(v0)]
impl VotingContractImpl of super::IVotingContract<ContractState> {
fn vote(ref self: ContractState, vote: felt252) {
assert((vote == 0) | (vote == 1), 'vote can only be 0/1');
let caller = get_caller_address();

assert(!self.voters.read(caller), 'you have already voted');

if vote == 0 {
self.no_votes.write(self.no_votes.read() + 1);
} else if vote == 1 {
self.yes_votes.write(self.yes_votes.read() + 1);
}

self.voters.write(caller, true);
}

fn get_votes(self: @ContractState) -> (felt252, felt252) {
(self.no_votes.read(), self.yes_votes.read())
}
}
}

Let’s deconstruct this contract:

The first segment,

#[starknet::interface]
trait IVotingContract<TContractState> {
fn vote(ref self: TContractState, vote: felt252);
fn get_votes(self: @TContractState) -> (felt252, felt252);
}

designates public functions, which are exposed and accessible from the outside world.

Within the contract storage,

#[storage]
struct Storage {
yes_votes: felt252,
no_votes: felt252,
voters: LegacyMap::<ContractAddress, bool>,
}

we store details regarding the total yes and no votes, along with who has already voted. Our contract restricts voters to a single vote.

The next function

fn get_votes(self: @ContractState) -> (felt252, felt252) {
(self.no_votes.read(), self.yes_votes.read())
}

permits the reading of votes from the state.

Lastly, the vote function

fn vote(ref self: ContractState, vote: felt252) {
assert((vote == 0) | (vote == 1), 'vote can only be 0/1');
let caller = get_caller_address();

assert(!self.voters.read(caller), 'you have already voted');

if vote == 0 {
self.no_votes.write(self.no_votes.read() + 1);
} else if vote == 1 {
self.yes_votes.write(self.yes_votes.read() + 1);
}

self.voters.write(caller, true);
}

addresses a more complex set of responsibilities including:

  1. Verification of vote value
  2. Authentication of a voter’s right to vote
  3. Update of the vote counter
  4. Storage of voter information

Understanding How Starknet Foundry Works

Before initiating tests on our contract, it’s important to understand how Starknet Foundry works. Each test creates a fresh instance of the Starknet blockchain imitation, executing the test function as if it would execute the invoke transaction. This provides a good test environment closely imitating the real Starknet blockchain. Starknet Foundry further simplifies testing by utilizing cheatcodes, which allow manipulation of our blockchain values, like changing the contract address or altering the block number.

Testing

Now, we can proceed with testing our contract. The first step deploy our contract on Starknet Foundry, which each test will need. We’ll create a helper for this purpose:

fn deploy_contract(name: felt252) -> ContractAddress {
let contract = declare(name);
contract.deploy(@array![]).unwrap()
}

Another helper will facilitate verification of the contract state:

fn expect_votes_counters_values(
contract_address: ContractAddress, expected_no_votes: felt252, expected_yes_votes: felt252
) {
let dispatcher = IVotingContractDispatcher { contract_address };
let (no_votes, yes_votes) = dispatcher.get_votes();
assert(no_votes == expected_no_votes, 'no votes value incorrect');
assert(yes_votes == expected_yes_votes, 'yes votes value incorrect');
}

In this helper, we fetch votes from the contract state and compare them with expected values.

Basic test

The test is executed inside the contract invoke transaction, so a Dispatcher is used to interact with the deployed contract. We can modify the state of the contract as well as query it. Here’s how a simple test would look:

#[test]
fn test_votes_counter_change() {
let contract_address = deploy_contract('VotingContract');

expect_votes_counters_values(contract_address, 0, 0);

let dispatcher = IVotingContractDispatcher { contract_address };
dispatcher.vote(1);

expect_votes_counters_values(contract_address, 0, 1);

}

Caller address manipulation

Previous test checks if the votes counter add a new vote. But what about situations where more than one voter is involved? In this case, we resort to using the start_prank cheatcode, which allows us to simulate situations with many voters. It works by override caller_address for our contract calls. To simplify, a helper will set the caller_address based on what we desire:

fn vote_with_caller_address(contract_address: ContractAddress, pranked_address: felt252, value: felt252) {
start_prank(contract_address, pranked_address.try_into().unwrap());

let dispatcher = IVotingContractDispatcher { contract_address };
dispatcher.vote(value);
}

As such, a check for instances where multiple voters participate would look like this:

#[test]
fn test_voting_multiple_times_from_different_accounts() {
let contract_address = deploy_contract('VotingContract');

expect_votes_counters_values(contract_address, 0, 0);

vote_with_caller_address(contract_address, 101, 1);
vote_with_caller_address(contract_address, 102, 1);
vote_with_caller_address(contract_address, 103, 0);
vote_with_caller_address(contract_address, 104, 0);
vote_with_caller_address(contract_address, 105, 0);

expect_votes_counters_values(contract_address, 3, 2);
}

This test enables each vote_with_caller_address to set a different caller_address via start_prank, culminating in a 5-count vote.

Testing errors

The last test evaluates cases where one voter attempts to vote twice, which isn’t allowed. Here, we use a SafeDispatcher that returns a Result enum, making it easy to check if we receive the appropriate error:

#[test]
fn test_voting_twice_from_the_same_account() {
let contract_address = deploy_contract('VotingContract');

start_prank(contract_address, 123.try_into().unwrap());

let safe_dispatcher = IVotingContractSafeDispatcher { contract_address };

safe_dispatcher.vote(1).unwrap();

match safe_dispatcher.vote(0) {
Result::Ok(_) => panic(array!['shouldve panicked']),
Result::Err(panic_data) => {
assert(*panic_data.at(0) == 'you have already voted', *panic_data.at(0));
}
}
}

And that’s it! You should now have the basic knowledge needed to start creating your own tests. To get an even deeper understanding of this toolkit, the Starknet Foundry Book is worth a read. If you stumble upon any questions or if you’re feeling uncertain about something, don’t hesitate to ask on the Starknet Foundry Telegram channel.

--

--