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.
Additionally, to run the tests you need to:
set up a local testnet node using your preferred platform: Windows, MacOS, Ubuntu, or Docker/Linux
create an account for testnet. The account will need some CCD to run tests.
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!
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!("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
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.
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
.
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.
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:
A call to
panic!
should be replaced withfail!
.The
expect
andexpect_err
function should be replaced withexpect_report
andexpect_err_report
.assert
andassert_eq
should be replaced withclaim!
andclaim_eq!
respectively.
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.
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.