Sui Move Language - Testing
Hello everyone.
Welcome to another day of exploring Web3 Engineering. In this series, we are learning smart contract development for Sui blockchain network using Move language. In the previous blog, I have shown you how to write your first smart contract and publish it. Now let us write some unit tests for that. So, without any further ado, let's get started.
Here is the link for the previous blog to follow: https://blog.jnalam.dev/sui-move-language-counter-contract
In the contract, to access the object fields outside, we need to create getter functions for them since the object fields are private to the module. After adding those, the contract will look like this.
/// Module: counter
module counter::counter {
const ENegativeDecrement: u64 = 0;
public struct Counter has key,store{
id: UID,
val: u64
}
public fun new(ctx: &mut TxContext): Counter {
Counter {
id: object::new(ctx),
val:0
}
}
public fun increment(c: &mut Counter) {
c.val = c.val + 1
}
public fun decrement(c: &mut Counter) {
assert!(c.val > 0, ENegativeDecrement);
c.val = c.val - 1
}
public fun val(c: &Counter) :u64 {
c.val
}
}
Scenario Module
Now let us write the test cases in the test/counter_tests.move
file. All the test contracts must contains a #[test_only]
decorator on top of it. This will notify the Sui cli that is just a test file, so that it won't be added to the package while publishing. In this contract, unit test should have the #[test]
to specify it as the test case, so that when we run the test command, it will be called. This is how an empty test file should look like this:
#[test_only]
module counter::counter_tests {
// uncomment this line to import the module
// use counter::counter;
const ENotImplemented: u64 = 0;
#[test]
fun test_counter() {
// pass
}
#[test, expected_failure(abort_code = counter::counter_tests::ENotImplemented)]
fun test_counter_fail() {
abort ENotImplemented
}
}
And also for testing the abort conditions, Sui also provides expected_failure
decorator. In which we can also pass the abort code for specifying certain conditions.
For testing, Sui package has test_scenario
which we can use to create test scenarios.
The test_scenario has the following modules
begin: To create a new test scenario. This function takes sender address as a parameter and will return the scenario object.
end: To end the test scenario
next_tx: To initiate the next transaction in the scenario and to retrieve the previous transactions details. The object returned contains some helper functions to get the details. They are
created: To get the ids of the objects created.
shared: To get the ids of the objects shared.
num_user_events - To get the number of events emitted
transferred_to_account - To get the ids of the objects sent to the user.
take_shared - To get the shared object
return_shared - To return the shared object
take_from_sender_by_id - To get the user owned object by id by passing object id.
take_from_sender - To get all the user owned object from the scenario by passing reference of the scenario and type of the object.
ctx - To get the transaction context of the scenario.
Test cases
Initialisation
The first test case is to evaluate the newly created Counter object. And the test condition is to check whether the initialised val
is 0 or not.
#[test]
fun test_new_counter() {
let user = @0x12e4;
let mut scenario = test_scenario::begin(user);
{
let ctx = scenario.ctx();
let c = counter::new(ctx);
assert!(c.val() == 0, EInvalidCounterInit);
transfer::public_transfer(c, scenario.sender());
};
scenario.end();
}
In the above test case, we are creating a dummy user with address @0x12e4
and then using that address to make the transactions. In the above transaction block, we are creating the Counter object and transferring that to the sender.
NOTE: The transactions in the Sui blockchain network are very different from the transactions in the EVM blockchain. I will explain why we are doing it like this in the upcoming blogs.
Increment
Now, let us write the test case for the increment.
#[test]
fun test_increment() {
let user = @0x12e4;
let mut scenario = test_scenario::begin(user);
{
let ctx = scenario.ctx();
let c = counter::new(ctx);
assert!(c.val() == 0, EInvalidCounterInit);
transfer::public_transfer(c, scenario.sender());
};
scenario.next_tx(user);
{
let mut c = test_scenario::take_from_sender<counter::Counter>(&scenario);
c.increment();
assert!(c.val() == 1, EIncrementFailed);
transfer::public_transfer(c, scenario.sender());
};
scenario.end();
}
Here we are creating a new counter in the first transaction and then in the second transaction, we are incrementing it. The increment function requires a mutable reference, so we are grabbing the mutable object from the user using take_from_sender
from the test_scenario
module and calling the increment
function on it.
Decrement
The decrement function contains an assert condition in our contract, so it has 2 test scenarios
Expected error when trying to decrement a 0 valued counter.
A successful decrement of the value
Test case for the error condition will look like this:
#[test, expected_failure(abort_code= counter::ENegativeDecrement)]
fun test_decrement_fail() {
let user = @0x12e4;
let mut scenario = test_scenario::begin(user);
{
let ctx = scenario.ctx();
let mut c = counter::new(ctx);
c.decrement();
transfer::public_transfer(c, scenario.sender());
};
scenario.end();
}
Here we have decorated the test case with expected_failure with an abort code, which means the test case is expected to fail with the given error value. Else the test case will not pass.
Test case for the successful decrement will look like this
#[test]
fun test_decrement() {
let user = @0x12e4;
let mut scenario = test_scenario::begin(user);
{
let ctx = scenario.ctx();
let mut c = counter::new(ctx);
c.increment();
c.increment();
assert!(c.val() == 2, EInvalidCounterInit);
transfer::public_transfer(c, scenario.sender());
};
scenario.next_tx(user);
{
let mut c = test_scenario::take_from_sender<counter::Counter>(&scenario);
c.decrement();
assert!(c.val() == 1, EIncrementFailed);
transfer::public_transfer(c, scenario.sender());
};
scenario.end();
}
Here we are the increment the value 2 times in the first transaction. Which means the value should equals to 2. And in the second transaction, I am decrementing it once which means the value should equal to 1.
The full code can be found here: https://github.com/jveer634/sui_contracts/blob/master/counter/tests/counter_tests.move
Now run the test cases with the command
sui move test
And the output should look like this
In the upcoming articles, let us look into the Sui transactions or also called as Programmable Transaction Blocks (PTBs).
Subscribe to my newsletter
Read articles from Jay Nalam directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Jay Nalam
Jay Nalam
Hi, I'm Jay Nalam, a seasoned Web3 Engineer committed to advancing decentralized technologies. Specializing in EVM-based blockchains, smart contracts, and web3 protocols, I've developed NFTs, DeFi protocols, and more, pushing boundaries in the crypto realm.