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:

Test draft

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

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.

Defining the test setup

Now let’s instantiate the 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

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:

 

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. 🚀