Testing the piggy bank smart contract#

This is the second part of a tutorial on smart contract development. So far you have written a piggy bank smart contract in the Rust programming language. This part will focus on how you can write unit tests for your 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.

Preparation#

Before you start, make sure to have the necessary tooling to build Rust contracts. The guide Install tools for development shows you how to do this. Also, make sure to have a text editor setup to write Rust.

Since you are going to extend the smart contract code written in the previous part, either follow the previous part or copy the complete example code for part 1 from GitHub.

You are now ready to write unit tests for your smart contract!

Note

To request CCDs for testing, use the buttons in the Concordium Wallets when running Testnet.

Add a test module#

Since a smart contract module is written as a Rust library, you 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 your test module, which is a common pattern for writing unit tests in Rust, so the above code will not be explained here.

Test the contract functions just as if they were regular functions by calling the functions you have annotated with #[init] and #[receive].

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

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

Now you can start adding tests to this module.

Testing instantiation of a piggy bank#

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

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

As mentioned above, you test the initialization by calling the function piggy_init directly. To construct its arguments, you use TestInitContext, which provides a placeholder for the context, and TestStateBuilder, which provides a state builder for the test state. While the contract doesn’t use the state builder, it still needs the argument supplied.

let ctx = TestInitContext::empty();
let mut state_builder = TestStateBuilder::new();

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 the piggy bank is not reading anything from the context.

Note

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

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

let state_result = piggy_init(&ctx, &mut state_builder);

First, you want the test to fail if the contract did not result in an initial state:

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

Next, you 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, you 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 = TestInitContext::empty();
        let mut state_builder = TestStateBuilder::new();

        let state_result = piggy_init(&ctx, &mut state_builder);

        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

Test inserting CCD into a piggy bank#

Next, you should test the different functions for interacting with a piggy bank. This works similarly to how you test init functions, in that we construct test versions of the arguments. For receive functions that means constructing TestReceiveContext and TestHost, the latter of which expects the initial contract state.

To test piggy_insert you also need to provide some amount of CCD:

let ctx = TestReceiveContext::empty();
let host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
let amount = Amount::from_micro_ccd(100);

When calling piggy_insert you get back a result with a return value as opposed to the initial state that you get from piggy_init:

let result = piggy_insert(&ctx, &host, amount);

The test then checks whether the result was ok:

assert!(result.is_ok(), "Inserting CCD results in error");

One test that is tempting to add is to check that the piggy bank remains intact after inserting CCD into it:

assert_eq!(
    *host.state(),
    PiggyBankState::Intact,
    "Piggy bank state should still be intact."
);

However, there is no way for the immutable receive method piggy_insert to mutate the state. Trying to do so would result in an error from the Rust compiler. By using immutable receive functions, it is possible to rule out certain error cases at compile time, which means that you do not need tests for these scenarios. Along with performance, those are the two primary reasons for not making your receive methods mutable unless strictly necessary.

The second test becomes:

#[test]
fn test_insert_intact() {
    let ctx = TestReceiveContext::empty();
    let host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
    let amount = Amount::from_micro_ccd(100);

    let result = piggy_insert(&ctx, &host, amount);

    assert!(result.is_ok(), "Inserting CCD results in error");
}

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

Next, you could add a test to check that inserting into a piggy bank with state Smashed results in an error. You have been through everything needed to do this, and can do this exercise on your own.

Test smashing a piggy bank#

Testing piggy_smash will follow the same pattern, but this time you 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. You also need to set the balance on the host.

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

let mut ctx = TestReceiveContext::empty();

Create an AccountAddress to represent the owner and use the setter set_owner implemented on TestReceiveContext:

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

Note

You 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, set the sender to be the same address as the owner using set_sender. Since the sender can be a contract instance as well, you must wrap the owner address in the Address type:

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

Lastly, you need to create a TestHost with the state and set the balance of the piggy bank instance using set_self_balance.

let mut host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
let balance = Amount::from_micro_ccd(100);
host.set_self_balance(balance);

Now that you have the test context setup, call the contract function piggy_smash and inspect the result and state as you did in the previous tests.

let result = piggy_smash(&ctx, &mut host);

assert!(result.is_ok(), "Smashing intact piggy bank results in error.");
assert_eq!(*host.state(), PiggyBankState::Smashed, "Piggy bank should be smashed.");

You should also test whether the contract transferred all of its CCD to the owner. TestHost has a number of helper functions for checking the results of actions it performed. This includes the get_transfers function, which returns a list of transactions in the form of (AccountAddress, Amount) pairs. In this case, it should be a single transaction:

assert_eq!(
    host.get_transfers(),
    [(owner, balance)],
    "Smashing did not produce the correct transfers."
);

The complete third test thus becomes:

#[test]
fn test_smash_intact() {
    let mut ctx = TestReceiveContext::empty();
    let owner = AccountAddress([0u8; 32]);
    ctx.set_owner(owner);
    let sender = Address::Account(owner);
    ctx.set_sender(sender);
    let mut host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
    let balance = Amount::from_micro_ccd(100);
    host.set_self_balance(balance);

    let result = piggy_smash(&ctx, &mut host);

    assert!(result.is_ok(), "Smashing intact piggy bank results in error.");
    assert_eq!(*host.state(), PiggyBankState::Smashed, "Piggy bank should be smashed.");
    assert_eq!(
        host.get_transfers(),
        [(owner, balance)],
        "Smashing did not produce the correct transfers."
    );
}

Ensure everything compiles and the test succeeds using cargo test.

Next, you could add a test to check that the piggy_view method returns the correct state and balance. Again, you have been through everything needed to do this, and can do this exercise on your own.

Testing cause of rejection#

You want to test that the 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 = TestReceiveContext::empty();
    let owner = AccountAddress([0u8; 32]);
    ctx.set_owner(owner);
    let sender = Address::Account(AccountAddress([1u8; 32]));
    ctx.set_sender(sender);
    let mut host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
    let balance = Amount::from_micro_ccd(100);
    host.set_self_balance(balance);

    let result = piggy_smash(&ctx, &mut host);

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

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

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

#[derive(Debug, PartialEq, Eq, Serial, Reject)]
enum SmashError {
    NotOwner,
    AlreadySmashed,
    TransferError, // Should never occur, see details below.
}

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", mutable)]
fn piggy_smash<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &mut impl HasHost<PiggyBankState, StateApiType = S>,
) -> Result<(), SmashError> {
   // ...
}

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

#[receive(contract = "PiggyBank", name = "smash", mutable)]
fn piggy_smash<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &mut impl HasHost<PiggyBankState, StateApiType = S>,
) -> Result<(), SmashError> {
    let owner = ctx.owner();
    let sender = ctx.sender();

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

    *host.state_mut() = PiggyBankState::Smashed;

    let balance = host.self_balance();
    let transfer_result = host.invoke_transfer(&owner, balance);
    ensure!(transfer_result.is_ok(), SmashError::TransferError);
    Ok(())
}

The invoke_transfer fails if the account does not exist, or if the contract has insufficient funds. Neither case can occur in the contract since contracts always have a valid owner and the amount it sends is the self_balance. But you should still be able to represent this error and distinguish it from the two other error types.

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

#[test]
fn test_smash_intact_not_owner() {
    let mut ctx = TestReceiveContext::empty();
    let owner = AccountAddress([0u8; 32]);
    ctx.set_owner(owner);
    let sender = Address::Account(AccountAddress([1u8; 32]));
    ctx.set_sender(sender);
    let mut host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
    let balance = Amount::from_micro_ccd(100);
    host.set_self_balance(balance);

    let result = piggy_smash(&ctx, &mut host);

    assert_eq!(result, Err(SmashError::NotOwner), "Expected to fail with error NotOwner.");
}

It is up to the reader to test whether smashing a piggy bank that has already been smashed results in the correct error.

See also

For more information on unit testing, see Unit test a contract in Rust.

Compiling and running tests in Wasm#

When running cargo test your 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. Luckily, the cargo-concordium tool contains such an interpreter, and it is the same interpreter shipped with the official nodes on the Concordium blockchain.

Before you can run tests in Wasm, you have to replace #[cfg(test)] at the top of your 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() {
        // ...
    }
}

You also need to modify the 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 you need generate code reporting the error back to the host who is running the Wasm. To do so, concordium-std provides replacements:

All of these macros are wrappers, which behave the same as their counterpart except when you build your smart contract for testing in Wasm using cargo-concordium. This means you 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 = TestInitContext::empty();
       let mut state_builder = TestStateBuilder::new();

       let state_result = piggy_init(&ctx, &mut state_builder);

       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 = TestReceiveContext::empty();
       let host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
       let amount = Amount::from_micro_ccd(100);

       let result = piggy_insert(&ctx, &host, amount);

       claim!(result.is_ok(), "Inserting CCD results in error");
   }

   #[concordium_test]
   fn test_smash_intact() {
       let mut ctx = TestReceiveContext::empty();
       let owner = AccountAddress([0u8; 32]);
       ctx.set_owner(owner);
       let sender = Address::Account(owner);
       ctx.set_sender(sender);
       let mut host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
       let balance = Amount::from_micro_ccd(100);
       host.set_self_balance(balance);

       let result = piggy_smash(&ctx, &mut host);

       claim!(result.is_ok(), "Smashing intact piggy bank results in error.");
       claim_eq!(*host.state(), PiggyBankState::Smashed, "Piggy bank should be smashed.");
       claim_eq!(
           host.get_transfers(),
           [(owner, balance)],
           "Smashing did not produce the correct transfers."
       );
   }

   #[concordium_test]
   fn test_smash_intact_not_owner() {
       let mut ctx = TestReceiveContext::empty();
       let owner = AccountAddress([0u8; 32]);
       ctx.set_owner(owner);
       let sender = Address::Account(AccountAddress([1u8; 32]));
       ctx.set_sender(sender);
       let mut host = TestHost::new(PiggyBankState::Intact, TestStateBuilder::new());
       let balance = Amount::from_micro_ccd(100);
       host.set_self_balance(balance);

       let result = piggy_smash(&ctx, &mut host);

       claim_eq!(result, 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 your smart contract module, exporting all of your tests as functions, and it will then run each of these functions catching the reported errors. This should succeed if everything is set up correctly. Otherwise, compare your code with the one found on GitHub.

Simulating the piggy bank#

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

You should also test the smart contract wasm module meant for deployment, and you 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 a reference of the context, see Simulation contexts.

For more on how to run simulations, see Locally simulate contract functions.

To continue with the tutorial click here.