2. Testing the piggy bank smart contract

This is the second part of a tutorial on smart contract development. So far we have written a piggy bank smart contract in the Rust programming language. This part will focus on how we can write unit test for our piggy bank smart contract and how to setup and locally simulate an invocation of a smart contract.

Warning

The reader is assumed to have basic knowledge of what a blockchain and smart contract is, and some experience with Rust.

2.1. Preparation

Before we start, make sure to have the necessary tooling for building Rust contracts. The guide Install tools for development will show you how to do this. Also, make sure to have a text editor setup for writing Rust.

Since we are going to extend the smart contract code written in the previous part, either follow the previous part or copy the resulting code from there.

We are now ready for writing unit tests for our smart contract!

2.2. Adding a test module

Since a smart contract module is written as a Rust library, we can test it as one would test any library and write unit-tests as part of the Rust module.

At the bottom of the lib.rs file containing our code, make sure you have the following starting point:

// PiggyBank contract code up here

#[cfg(test)]
mod tests {
    use super::*;

}

This is our test module, which is a common pattern for writing unit tests in Rust, so we will not spend time on explaining any of the above code.

We test the contract functions just as if they were regular functions, by calling the functions we have annotated with #[init] and #[receive].

But in order to call them, we will need to first construct the arguments. Luckily concordium-std contains a submodule test_infrastructure with stubs for this, so let us first bring everything from the submodule into scope.

#[cfg(test)]
mod tests {
    use super::*;
    use test_infrastructure::*;

}

Now we can start adding tests to this module.

2.3. Testing instantiation of a piggy bank

The first test we add is to verify a piggy bank is set up with the correct state.

#[test]
fn test_init() {
   todo!("Implement")
}

As mentioned above, we test the initialization by calling the function piggy_init directly. To construct its argument for, we use InitContextTest, which provides a placeholder for the context.

let ctx = InitContextTest::empty();

Just as the name suggests, the test context is empty and if any of the getter functions are called, it will make sure to fail the test, which should be fine for now, since our piggy bank is not reading anything from the context.

Note

As we will see later with the ReceiveContextTest, these placeholders have setter functions, allowing us to partially specify the context.

Now we can call piggy_init and get a result containing the initial state.

let state_result = piggy_init(&ctx);

First of all we want the test to fail, if our contract did not result in an initial state:

let state = state_result.expect("Contract initialization results in error.");

Next we assert the state is correctly set to Intact:

assert_eq!(
   state,
   PiggyBankState::Intact,
   "Piggy bank state should be intact after initialization."
);

Putting it all together we end up with the following test for initializing a piggy bank:

// PiggyBank contract code up here

#[cfg(test)]
mod tests {
    use super::*;
    use test_infrastructure::*;

    #[test]
    fn test_init() {
        let ctx = InitContextTest::empty();

        let state_result = piggy_init(&ctx);

        let state = state_result.expect("Contract initialization results in error.");

        assert_eq!(
            state,
            PiggyBankState::Intact,
            "Piggy bank state should be intact after initialization."
        );
    }
}

Run the test to check that it compiles and succeeds.

$cargo test

2.4. Test inserting CCD into a piggy bank

Next we should test the different functions for interacting with a piggy bank. This is done in the same way as initializing, except we use ReceiveContextTest to construct the context.

To test piggy_insert we also need some amount of CCD and the current state of our smart contract instance:

let ctx = ReceiveContextTest::empty();
let amount = Amount::from_micro_gtu(100);
let mut state = PiggyBankState::Intact;

When calling piggy_insert we get back a result with actions, instead of an initial state as with piggy_init. But we will need to help the compiler infer which type to use for the generic A implementing HasActions, so we add the result type ReceiveResult<ActionsTree>:

let actions_result: ReceiveResult<ActionsTree> = piggy_insert(&ctx, amount, &mut state);

For testing we can represent the actions as a simple tree structure ActionsTree, making it easy to inspect.

Note

The #[receive] macro uses another representation of the actions, when building the smart contract module. This representation depends on functions supplied by the host environment and is therefore not suitable for unit tests.

Now we need to check if the function succeeded and verify the resulting state and actions. In our case the state should remain intact and the function produce only the action for accepting the CCD.

let actions = actions_result.expect("Inserting CCD results in error.");

assert_eq!(actions, ActionsTree::accept(), "No action should be produced.");
assert_eq!(state, PiggyBankState::Intact, "Piggy bank state should still be intact.");

The second test becomes:

#[test]
fn test_insert_intact() {
    let ctx = ReceiveContextTest::empty();
    let amount = Amount::from_micro_gtu(100);
    let mut state = PiggyBankState::Intact;

    let actions_result: ReceiveResult<ActionsTree> = piggy_insert(&ctx, amount, &mut state);

    let actions = actions_result.expect("Inserting CCD results in error.");

    assert_eq!(actions, ActionsTree::accept(), "No action should be produced.");
    assert_eq!(state, PiggyBankState::Intact, "Piggy bank state should still be intact.");
}

Again we should verify everything compiles and the tests succeeds using cargo test.

Next we could add a test, checking that inserting into a piggy bank with state Smashed results in an error, but we have been through everything needed to do this, and we therefore leave as an exercise for the reader.

2.5. Test smashing a piggy bank

Testing piggy_smash will follow the same pattern, but this time we will need to populate the context, since this function uses the context for getting the contract owner, the sender of the message triggering the function and the balance of contract.

If we just supply the function with an empty context it will fail, so instead we define the context as mutable:

let mut ctx = ReceiveContextTest::empty();

We create an AccountAddress to represent the owner and use the setter set_owner implemented on ReceiveContextTest:

let owner = AccountAddress([0u8; 32]);
ctx.set_owner(owner);

Note

Notice we created the account address using an array of 32 bytes, which is how account addresses are represented on the Concordium blockchain. These byte arrays can also be represented as a base58check encoding, but for testing it is usually more convenient to specify addresses directly in bytes.

Next we set the sender to be the same address as the owner using set_sender. Since the sender can be a contract instance as well, we must wrap the owner address in the Address type:

let sender = Address::Account(owner);
ctx.set_sender(sender);

Lastly we will need to set the current balance of the piggy bank instance, using set_self_balance.

let balance = Amount::from_micro_gtu(100);
ctx.set_self_balance(balance);

Now that we have the test context setup, we call the contract function piggy_smash and inspect the resulting action tree and state just like we did in the previous tests:

#[test]
fn test_smash_intact() {
    let mut ctx = ReceiveContextTest::empty();
    let owner = AccountAddress([0u8; 32]);
    ctx.set_owner(owner);
    let sender = Address::Account(owner);
    ctx.set_sender(sender);
    let balance = Amount::from_micro_gtu(100);
    ctx.set_self_balance(balance);

    let mut state = PiggyBankState::Intact;

    let actions_result: ReceiveResult<ActionsTree> = piggy_smash(&ctx, &mut state);

    let actions = actions_result.expect("Inserting CCD results in error.");
    assert_eq!(actions, ActionsTree::simple_transfer(&owner, balance));
    assert_eq!(state, PiggyBankState::Smashed);
}

Ensure everything compiles and the tests succeeds using cargo test.

2.6. Testing cause of rejection

We want to test that our piggy bank rejects in certain contexts, for example when someone besides the owner of the smart contract tries to smash it.

The test should:

  • Make a context where the sender and owner are two different accounts.

  • Set the state to be intact.

  • Call piggy_smash.

  • Check that the result is an error.

The test could look like this:

#[test]
fn test_smash_intact_not_owner() {
    let mut ctx = ReceiveContextTest::empty();
    let owner = AccountAddress([0u8; 32]);
    ctx.set_owner(owner);
    let sender = Address::Account(AccountAddress([1u8; 32]));
    ctx.set_sender(sender);
    let balance = Amount::from_micro_gtu(100);
    ctx.set_self_balance(balance);

    let mut state = PiggyBankState::Intact;

    let actions_result: ReceiveResult<ActionsTree> = piggy_smash(&ctx, &mut state);

    assert!(actions_result.is_err(), "Contract is expected to fail.")
}

One thing to notice is that the test is not ensuring why the contract rejected, our piggy bank might reject for a wrong reason, and this would be a bug. This is probably fine for a simple smart contract like our piggy bank, but for a smart contract with more complex logic and many reasons for rejecting, it would be better if we tested this as well.

To solve this we introduce a SmashError enum , to represent the different reasons for rejection:

#[derive(Debug, PartialEq, Eq, Reject)]
enum SmashError {
    NotOwner,
    AlreadySmashed,
}

See also

For more information about custom errors and deriving Reject, see Return custom errors.

To use this error type; the function piggy_smash should return Result<A, SmashError> instead of ReceiveResult<A>:

#[receive(contract = "PiggyBank", name = "smash")]
fn piggy_smash<A: HasActions>(
    ctx: &impl HasReceiveContext,
    state: &mut PiggyBankState,
) -> Result<A, SmashError> {
   // ...
}

and we also have to supply the ensure! macros with a second argument, which is the error to produce:

#[receive(contract = "PiggyBank", name = "smash")]
fn piggy_smash<A: HasActions>(
    ctx: &impl HasReceiveContext,
    state: &mut PiggyBankState,
) -> Result<A, SmashError> {
    let owner = ctx.owner();
    let sender = ctx.sender();

    ensure!(sender.matches_account(&owner), SmashError::NotOwner);
    ensure!(*state == PiggyBankState::Intact, SmashError::AlreadySmashed);

    *state = PiggyBankState::Smashed;

    let balance = ctx.self_balance();
    Ok(A::simple_transfer(&owner, balance))
}

Since the return type have changed for the piggy_smash function, we have to change the type in the tests as well:

#[test]
fn test_smash_intact() {
    // ...

    let actions_result: Result<ActionsTree, SmashError> = piggy_smash(&ctx, &mut state);

    // ...
}

#[test]
fn test_smash_intact_not_owner() {
    // ...

    let actions_result: Result<ActionsTree, SmashError> = piggy_smash(&ctx, &mut state);

    // ...
}

We can now check which error was produced in the test:

#[test]
fn test_smash_intact_not_owner() {
    let mut ctx = ReceiveContextTest::empty();
    let owner = AccountAddress([0u8; 32]);
    ctx.set_owner(owner);
    let sender = Address::Account(AccountAddress([1u8; 32]));
    ctx.set_sender(sender);
    let balance = Amount::from_micro_gtu(100);
    ctx.set_self_balance(balance);

    let mut state = PiggyBankState::Intact;

    let actions_result: Result<ActionsTree, SmashError> = piggy_smash(&ctx, &mut state);

    let err = actions_result.expect_err("Contract is expected to fail.");
    assert_eq!(err, SmashError::NotOwner, "Expected to fail with error NotOwner")
}

We leave it up to the reader to test, whether smashing a piggy bank, that have already been smashed results in the correct error.

2.7. Compiling and running tests in Wasm

When running cargo test our contract module and tests are compiled targeting your native platform, but on the Concordium blockchain a smart contract module is in Wasm. Therefore it is preferable to compile the tests targeting Wasm and run the tests using a Wasm interpreter instead. Lucky for us, the cargo-concordium tool contains such an interpreter, and it is the same interpreter shipped with the official nodes on the Concordium blockchain.

Before we can run our tests in Wasm, we have to replace #[cfg(test)] at the top of our test module with #[concordium_cfg_test] and all the #[test] macros with #[concordium_test].

// PiggyBank contract code up here

#[concordium_cfg_test]
mod tests {
    use super::*;
    use test_infrastructure::*;

    #[concordium_test]
    fn test_init() {
        // ...
    }

    #[concordium_test]
    fn test_insert_intact() {
        // ...
    }

    #[concordium_test]
    fn test_smash_intact() {
        // ...
    }

    #[concordium_test]
    fn test_smash_intact_not_owner() {
        // ...
    }
}

We will also need to modify our tests a bit. Usually a test in Rust is failed by panicking with an error message, but when compiling to Wasm this error message is lost. Instead we need generate code reporting the error back to the host, who is running the Wasm, and to do so, concordium-std provides replacements:

All of these macros are wrappers, which behaves the same as their counterpart except when we build our smart contract for testing in Wasm using cargo-concordium. This means we can still run tests for targeting native using cargo test.

// PiggyBank contract code up here

#[concordium_cfg_test]
mod tests {
   use super::*;
   use test_infrastructure::*;

   #[concordium_test]
   fn test_init() {
       let ctx = InitContextTest::empty();

       let state_result = piggy_init(&ctx);

       let state = state_result.expect_report("Contract initialization failed.");

       claim_eq!(
             state,
             PiggyBankState::Intact,
             "Piggy bank state should be intact after initialization."
       );
   }

   #[concordium_test]
   fn test_insert_intact() {
       let ctx = ReceiveContextTest::empty();
       let amount = Amount::from_micro_gtu(100);
       let mut state = PiggyBankState::Intact;

       let actions_result: ReceiveResult<ActionsTree> = piggy_insert(&ctx, amount, &mut state);

       let actions = actions_result.expect_report("Inserting CCD results in error.");

       claim_eq!(actions, ActionsTree::accept(), "No action should be produced.");
       claim_eq!(state, PiggyBankState::Intact, "Piggy bank state should still be intact.");
   }

   #[concordium_test]
   fn test_smash_intact() {
       let mut ctx = ReceiveContextTest::empty();
       let owner = AccountAddress([0u8; 32]);
       ctx.set_owner(owner);
       let sender = Address::Account(owner);
       ctx.set_sender(sender);
       let balance = Amount::from_micro_gtu(100);
       ctx.set_self_balance(balance);

       let mut state = PiggyBankState::Intact;

       let actions_result: Result<ActionsTree, SmashError> = piggy_smash(&ctx, &mut state);

       let actions = actions_result.expect_report("Inserting CCD results in error.");
       claim_eq!(actions, ActionsTree::simple_transfer(&owner, balance));
       claim_eq!(state, PiggyBankState::Smashed);
   }

   #[concordium_test]
   fn test_smash_intact_not_owner() {
       let mut ctx = ReceiveContextTest::empty();
       let owner = AccountAddress([0u8; 32]);
       ctx.set_owner(owner);
       let sender = Address::Account(AccountAddress([1u8; 32]));
       ctx.set_sender(sender);
       let balance = Amount::from_micro_gtu(100);
       ctx.set_self_balance(balance);

       let mut state = PiggyBankState::Intact;

       let actions_result: Result<ActionsTree, SmashError> = piggy_smash(&ctx, &mut state);

       let err = actions_result.expect_err_report("Contract is expected to fail.");
       claim_eq!(err, SmashError::NotOwner, "Expected to fail with error NotOwner")
   }
}

Compiling and running the tests in Wasm can be done using:

$cargo concordium test

This will make a special test build of our smart contract module exporting all of our tests as functions and it will then run each of these functions catching the reported errors.

2.8. Simulating the piggy bank

So far the tests we have written are in Rust and have to be compiled alongside the smart contract module in a test build, which is fine for unit testing, but this test build is not the actual module that we intend to deploy on the Concordium blockchain.

We should also test the smart contract wasm module meant for deployment, and we can use the simulate feature of cargo-concordium. It takes a smart contract wasm module and uses the Wasm interpreter to run a smart contract function in a given context.

For more on how to do this: check out the guide Locally simulate contract functions.

2.9. Support & Feedback

If you have questions or feedback, join us on Discourse, or contact us at support@concordium.software.