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

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

Additionally, to run the tests you need to:

Since you 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.

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

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

2.3. 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!("Implement")
}

As mentioned above, you test the initialization by calling the function piggy_init directly. To construct its argument for, you 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 the piggy bank is not reading anything from the context.

Note

As you will see later with the ReceiveContextTest, 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);

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 = 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 you should test the different functions for interacting with a piggy bank. This is done in the same way as initializing, except you use ReceiveContextTest to construct the context.

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

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

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

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

For testing you 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 you need to check if the function succeeded and verify the resulting state and actions. In this 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_ccd(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 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.

2.5. 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, and the balance of contract.

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

let mut ctx = ReceiveContextTest::empty();

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 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 set the current balance of the piggy bank instance using set_self_balance.

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

Now that you have the test context setup, call the contract function piggy_smash and inspect the resulting action tree and state as you 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_ccd(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 test succeeds using cargo test.

2.6. 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 = 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_ccd(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; 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, 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 you 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 has changed for the piggy_smash function, you 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);

    // ...
}

You 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_ccd(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")
}

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

2.7. 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 = 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_ccd(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_ccd(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_ccd(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 your smart contract module, exporting all of your 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 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.