Skip to content

Testing MultiversX (former Elrond) Smart Contracts

Testing is essential — everybody knows that. Sometimes testing can be hard, and when that's the case, it stops being fun and devs write fewer tests. But testing is super-important when writing smart contracts, as it is the only way to ensure good code quality and avoid bugs (and we all know what bugs in smart contracts can lead to). I’ll tell you how we test our contracts in a way that makes it a breeze.

Your smart contract should weather any storm (image created with DALL·E)

 

This blog post covers an introduction to the Given-When-Then pattern, followed by instructions on how to use that pattern to test smart contracts. I'll also talk about why we don't use Mandos too much.

Given-When-What?

Given-When-Then is a popular style to structure test scenarios. The idea is to split the scenario you want to test into three parts:

  • Given: What is the context of the test? What pre-conditions must be met before the action can be tested?
  • When: What is tested? Which action is executed?
  • Then: What is the expected result? What post-conditions should be met?

You can read more about it in one of these reference explanations.

Let’s put what we learned into Elrond practice.

Creating our first test

When writing tests, we take a top-down approach. We think of a scenario and break it down into the three phases. Let’s start with an example test for the ping-pong contract.

“A user can ping.”

We first want to ensure that a user can ping (we leave the pong part as an exercise for the reader).

What pre-conditions have to be met?

  • A ping-pong contract has to be deployed
  • The user must have sufficient EGLD

What is the action?

  • The user pings

What do we expect?

  • The ping succeeds
  • The user now has x EGLD less
  • The contract holds that x EGLD

Ideally, these six statements result in six human-readable lines of code that look similar to this:

given_contract(<contract params>);
given_user_has_egld(<egld amount>);
when_pinging(<egld amount>);
then_tx_successful();
then_user_has_egld(<expected egld amount>);
then_contract_has_egld(<expected egld amount>);
view raw test_draft.txt hosted with ❤ by GitHub

Test draft

Well, what can I tell you? It (almost) does.

#[test]
fn user_can_ping() {
given_a_deployed_contract(5_000_000, 1, None, None).run_test(|setup, users| {
given_user_has_egld(setup, &users.user1, 5_000_000);
let tx = when_pinging(setup, &users.user1, 5_000_000);
then_tx_should_succeed(&tx);
then_user_should_have_egld(setup, &users.user1, 0);
then_contract_should_have_egld(setup, 5_000_000);
});
}

Actual test code

Now let me show you what’s behind all these functions.

Some Boilerplate

To write given-when-then functions like this, you need to set up some boilerplate. You can mostly copy our code and only have to apply some minor adaptations. If you're unsure of where to put what, check out our Github Repo.

The core of our testing functions is the TestSetup which holds the blockchain, the contract, and the users. It also provides a function to run a test. Don’t worry too much about the definitions of ContractBase and ContractBuilder. If you’re not a Rust wizard yet, you can take them as they come.

use example_contract::*;
type ContractBase = example_contract::ContractObj<DebugApi>;
pub trait ContractBuilder = where Self: 'static + Copy + Fn() -> ContractBase;
pub struct Users {
pub owner: Address,
pub user1: Address,
pub user2: Address,
}
pub struct TestSetup<T: ContractBuilder> {
pub blockchain_wrapper: BlockchainStateWrapper,
pub users: Users,
pub contract: ContractObjWrapper<ContractBase, T>,
}
impl<T> TestSetup<T>
where
T: ContractBuilder,
{
pub fn run_test(&mut self, test_fn: fn(setup: &mut TestSetup<T>, user: &Users)) {
let users = self.users.clone();
test_fn(self, &users);
}
}
view raw test_setup.rs hosted with ❤ by GitHub

Defining the test setup

Now let’s instantiate the setup:

pub fn prepare_setup<T: ContractBuilder>(dao_contract_builder: T) -> TestSetup<T> {
let mut blockchain_wrapper = BlockchainStateWrapper::new();
let owner = blockchain_wrapper.create_user_account(&rust_biguint!(0));
let contract_wrapper = blockchain_wrapper.create_sc_account(
&rust_biguint!(0),
Some(&owner),
dao_contract_builder,
WASM_PATH,
);
let user1 = blockchain_wrapper.create_user_account(&rust_biguint!(0));
let user2 = blockchain_wrapper.create_user_account(&rust_biguint!(0));
TestSetup {
blockchain_wrapper,
users: Users {
owner,
user1,
user2,
},
contract: contract_wrapper,
}
}
pub fn deploy_contract<T: ContractBuilder>(
contract_setup: &mut TestSetup<T>,
ping_amount: u64,
duration_in_seconds: u64,
opt_activation_timestamp: Option<u64>,
max_funds: Option<u64>,
) -> TxResult {
contract_setup.blockchain_wrapper.execute_tx(
&contract_setup.users.owner,
&contract_setup.contract,
&rust_biguint!(0),
|sc| {
sc.init(
&elrond_wasm::types::BigUint::from(ping_amount),
duration_in_seconds,
opt_activation_timestamp,
max_funds.map(elrond_wasm::types::BigUint::from).into(),
);
},
)
}
pub fn given_a_deployed_contract(
ping_amount: u64,
duration_in_seconds: u64,
opt_activation_timestamp: Option<u64>,
max_funds: Option<u64>,
) -> TestSetup<impl ContractBuilder> {
let mut setup = prepare_setup(example_contract::contract_obj);
setup.blockchain_wrapper.set_block_timestamp(DEPLOY_TIME);
deploy_contract(
&mut setup,
ping_amount,
duration_in_seconds,
opt_activation_timestamp,
max_funds,
)
.assert_ok();
setup
}

Deploying the contract

This process is split up into two parts. The first is preparing the setup, and the second is deploying the contract. We want to separate these processes because while the first part is the same for each contract, the second part varies depending on the parameters. The function given_a_deployed_contract combines these two parts and is actually the function with which we will start most of our tests.

Writing actions

The function when_pinging looks like this

pub fn when_pinging<T: ContractBuilder>(
setup: &mut TestSetup<T>,
user: &Address,
amount: u128,
) -> TxResult {
setup
.blockchain_wrapper
.execute_tx(&user, &setup.contract, &rust_biguint!(amount), |sc| {
sc.ping(IgnoreValue {});
})
}
view raw when_pinging.rs hosted with ❤ by GitHub

Definition of when_pinging

We take the test setup and execute a transaction on it. An important thing here is that we don’t yet check the outcome. We just return the transaction result and leave the assertions to the then part.

“Then,” we assert

The function to assert how much EGLD a user should have at the end looks like this:

 
pub fn then_user_should_have_egld<T: ContractBuilder>(
setup: &mut TestSetup<T>,
user: &Address,
expected_balance: u128,
) {
let balance = setup.blockchain_wrapper.get_egld_balance(user);
assert_eq!(
balance,
rust_biguint!(expected_balance),
"expected to have {} EGLD, but was {} EGLD",
expected_balance,
balance
);
}

Then the user should have EGLD

Why all the hassle?

You may ask now, why do we jump through all these hoops? The answer is that it pays off quickly. After writing those given-when-then functions, you will soon realize that you can reuse them in many different testing scenarios, almost like putting together lego. This ultimately makes writing tests fun and more intuitive.

A word on Mandos

While the idea of Mandos is super cool, it is very cumbersome to write tests. The specification of parameters is quite low-level which makes it especially difficult to write complex tests. Check out the following code snippet for example (taken from here):

 

"storage": {
"...": "...",
"str:owned_tokens|address:artist1|str:.info": "u32:2|u32:1|u32:2|u32:2",
"str:owned_tokens|address:artist1|str:.node_links|u32:1": "u32:0|u32:2",
"str:owned_tokens|address:artist1|str:.node_id|nested:str:MFFT-123456": "1",
"str:owned_tokens|address:artist1|str:.value|u32:1": "str:MFFT-123456",
"str:owned_tokens|address:artist1|str:.node_links|u32:2": "u32:1|u32:0",
"str:owned_tokens|address:artist1|str:.node_id|nested:str:MFNFT-567890": "2",
"str:owned_tokens|address:artist1|str:.value|u32:2": "str:MFNFT-567890",
"str:owned_tokens|address:artist2|str:.info": "u32:1|u32:1|u32:1|u32:1",
"str:owned_tokens|address:artist2|str:.node_links|u32:1": "u32:0|u32:0",
"str:owned_tokens|address:artist2|str:.node_id|nested:str:MFSFT-246802": "1",
"str:owned_tokens|address:artist2|str:.value|u32:1": "str:MFSFT-246802"
},

 

This snippet shows what’s necessary to define two users owning some tokens. The corresponding definition of the storage mapper looks like this:

 

#[storage_mapper("owned_tokens")]
fn owned_tokens(&self, owner: &ManagedAddress) -> SetMapper<TokenIdentifier>;

 

Don’t get me wrong, a responsible MultiversX developer should fundamentally understand how data is stored in the smart contract to choose the right data structure and program as cost-efficient as possible but it should not be necessary to understand in detail how a SetMapper saves its data to write tests. In our proposed testing style, the definition would look something like this:

 

given_user_has_tokens(setup, user1, "MFFT-123456", &[1]);
given_user_has_tokens(setup, user1, "MFNFT-567890", &[2]);
given_user_has_tokens(setup, user2, "MFSFT-246802", &[1]);

It's time to build!

You can check out the source code of the example contract in this Github Repo. We at &amp write Elrond Contracts (and other Web3 stuff, too!). If you’re feeling stuck or just need an extra set of hands, we’re happy to bring your project to the moooon. 🚀