Sui Move Language - Testing

Jay NalamJay Nalam
5 min read

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

  1. Expected error when trying to decrement a 0 valued counter.

  2. 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).

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