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 integration-tests for your piggy bank smart
contract using the concordium-smart-contract-testing
library.
The library simulates part of a blockchain locally to allow you to create one or more contracts and interact with them in the tests.
Preparation#
Before you start, make sure to have the necessary tooling to build Rust contracts. The guide Setup the development environment shows you how to do this.
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 tests for your smart contract!
Adding the testing library#
Start by adding the concordium-smart-contract-testing
library to the Cargo.toml
located in the project root.
Then, add the concordium-std-derive
library as well, which contains useful macros for testing.
You should add them under the section [dev-dependencies]
, which are dependencies only needed during development, as it is only needed during testing.
The libraries require the Rust edition 2021
or greater, which you must also set:
[package]
# ...
edition = "2021"
[dev-dependencies]
concordium-smart-contract-testing = "3.0"
concordium-std-derive = "6.0"
Add a test module#
Since a smart contract module is a regular Rust library, you can test it as
one would test any library and add integration tests in the tests
folder.
Create the folder tests
in the root of your project and add the file tests.rs
inside it.
Import the testing libraries and your contract at the top of the file.
use concordium_smart_contract_testing::*;
use concordium_std_derive::*;
use piggy_bank_part2::*;
Now you can start adding tests to this module.
Testing instantiation of a piggy bank#
The first test is to verify that the piggy bank contract can be initialized correctly. Writing it also teaches you the basics of using the testing library.
Start by creating three constants that you will use in most of the upcoming test cases.
// .. Imports omitted for brevity
const ACC_ADDR_OWNER: AccountAddress = account_address!("<your_cryptoX_wallet_address>");
const ACC_ADDR_OTHER: AccountAddress = account_address!("2xdTv8awN1BjgYEw8W1BVXVtiEwG2b29U8KoZQqJrDuEqddseE");
const ACC_INITIAL_BALANCE: Amount = Amount::from_ccd(1000);
The first two are account addresses and the last one is the initial balance that both accounts will have.
Note
The account addresses are represented as a base58check encoded string.
Next, create the init test:
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
todo!()
}
Tests in Rust are regular functions, and the #[test]
attribute tells the test runner to include the function when running cargo test
or cargo concordium test
.
The primary construct used for testing is the Chain
type, which you should only create one of per test.
It represents the blockchain and has methods for creating accounts and deploying and working with contracts.
Use the Chain::new
method for creating a chain with default settings and save it to the mutable variable chain
:
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
let mut chain = Chain::new();
}
The next step is to create two Account
entities and add them to the chain
.
The simplest way to create an account is with Account::new
, which takes an AccountAddress
and a total balance of the account.
Once constructed, use the create_account
method to add it to the chain.
This step is important, as simply constructing an Account
does not make the chain aware of it.
Use the addresses and initial balance defined as constants:
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
let mut chain = Chain::new();
let account_owner = Account::new(ACC_ADDR_OWNER, ACC_INITIAL_BALANCE);
let account_other = Account::new(ACC_ADDR_OTHER, ACC_INITIAL_BALANCE);
chain.create_account(account_owner);
chain.create_account(account_other);
}
The balances of the accounts matter when using the testing library, as the cost of transactions, for example deploying a smart contract, will be deducted from the balance of the account sending the transaction.
With the accounts created, you are ready to load and deploy the smart contract module.
In concordium-smart-contract-testing
you test the compiled smart contract module, which is the exact same module that can be deployed on the blockchain.
Use cargo concordium
, which you installed in preparation for this tutorial, to compile the piggy bank to WebAssembly (Wasm).
Open a terminal and use cd
, short for change directory, to go into the root of your piggy bank project.
Then compile your contract with:
$cargo concordium build --out piggy_bank_part2.wasm.v1
This produces the Wasm module piggy_bank_part2.wasm.v1
in the root of your project, unless you have any typos or bugs in your code.
If that is the case, try to fix them using the helpful error messages from the compiler or go back to the end of part 1 and copy the full contract code again.
Note
Since the tests run against the compiled Wasm module, there is a risk of accidentally using an outdated Wasm module.
To circumvent this, cargo concordium’s test command both builds and tests you contract, which ensures that you always test the newest version of your code.
By default it places the compiled Wasm module in the target/
folder, but you can specify where you want it placed, so the location is easy to specify in your tests.
To do so, use the --out
parameter when testing:
$cargo concordium test --out piggy_bank_part2.wasm.v1
Please note that the test command only builds your module in cargo concordium version 2.9.0+. Also note that for the highest assurance of correctness, you should deploy the exact module that you also tested.
Going back to your test case, use the function module_load_v1
to load the module.
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
// .. lines omitted for brevity.
let module = module_load_v1("piggy_bank_part2.wasm.v1").expect("Module is valid and exists");
}
module_load_v1
attempts to load a module from disk, which might be missing or invalid, and it thus returns a Result
type.
You can use unwrap
, or expect
, to extract the actual module from the Result
.
Both methods will panic if the Result
actually contains the Err
variant, which in turn will make the test case fail.
The remainder of this tutorial uses expect
as it allows you to provide a contextual message that is shown on panics.
The next step is to deploy the module to our test chain (chain
) with the method module_deploy_v1
.
Since this is a transaction, you must provide an account address of the sender
, which will pay for the cost of the transaction.
You must also provide a Signer
with a number of keys.
This mimics the behavior on the real chain, where one or more keys must sign a transaction.
The only observable difference between using one or more keys is the cost of the transaction, where each extra key increases the cost slightly.
In this tutorial, you will always use a Signer
with one key as that is the most common scenario.
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
let mut chain = Chain::new();
// .. lines omitted for brevity.
let module = module_load_v1("piggy_bank_part2.wasm.v1").expect("Module is valid and exists");
let deployment = chain
.module_deploy_v1(
Signer::with_one_key(),
ACC_ADDR_OWNER,
module)
.expect("Deploying valid module should succeed");
}
Since deployment can fail, for example if the account doesn’t have sufficient CCD to cover the cost, the method returns a Result
, which is unwrapped with expect
.
The returned struct has information about the energy used, transaction fee, and a ModuleReference
that you use for initializing contracts.
With the module deployed, you are ready to initialize a contract with the chain method contract_init
.
The method has the following parameters:
A
Signer
to sign the transaction.An
AccountAddress
, which pays for the transaction.A maximum
Energy
that the contract initialization can use.A
ModuleReference
, which you got from the deployment above.An
OwnedContractName
, that specifies which contract in the module you want to initialize. Contract names are prefixed withinit_
on the chain to distinguish them from receive functions (entrypoints). You constuct it with eitherOwnedContractName::new
, which checks the validity and returns aResult
, orOwnedContractName::new_unchecked
, which performs no checking.An
OwnedParameter
, which is a wrapper over a byte array that you construct with one of the following methods:OwnedParameter::from_serial
, which serializes the input and checks that the parameter size is valid,TryFrom::<Vec<u8>>::try_from(..)
, which also checks the parameter size,or
OwnedParameter::empty
, which always succeeds.
An
Amount
to send to the contract.
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
// .. lines omitted for brevity.
let initialization = chain
.contract_init(
Signer::with_one_key(),
ACC_ADDR_OWNER,
Energy::from(10000),
InitContractPayload {
mod_ref: deployment.module_reference,
init_name: OwnedContractName::new_unchecked("init_PiggyBank".to_string()),
param: OwnedParameter::empty(),
amount: Amount::zero(),
}
)
.expect("Initialization should always succeed");
}
Initialization can fail for several different reasons, and thus returns a Result
, which is unwrapped with expect
.
The returned struct contains information about the energy used, transaction fee, contract events (logs) produced, and a ContractAddress
that you use for updating and interacting with the contract.
While the deployment and initialization in themselves act as a test, you can also check that the balance starts out as zero.
Use the method contract_balance
with the ContractAddress
from the initialization
struct to do so:
// .. Imports and constants omitted for brevity
#[test]
fn test_init() {
// .. lines omitted for brevity.
assert_eq!(
chain.contract_balance(initialization.contract_address),
Some(Amount::zero()),
"Piggy bank is not initialized with balance of zero"
);
}
contract_balance
returns an Option<Amount>
, as the contract queried might not exist.
The remaining test cases will call methods on an initialized contract.
To avoid duplicating code across the test cases, you will refactor nearly all of test_init
into a separate helper function, setup_chain_and_contract
, which you will use in the tests.
After the refactoring, you end up with the following test for initializing a piggy bank:
use concordium_smart_contract_testing::*;
const ACC_ADDR_OWNER: AccountAddress = AccountAddress([0u8; 32]);
const ACC_ADDR_OTHER: AccountAddress = AccountAddress([1u8; 32]);
const ACC_INITIAL_BALANCE: Amount = Amount::from_ccd(1000);
fn setup_chain_and_contract() -> (Chain, ContractInitSuccess) {
let mut chain = Chain::new();
chain.create_account(Account::new(ACC_ADDR_OWNER, ACC_INITIAL_BALANCE));
chain.create_account(Account::new(ACC_ADDR_OTHER, ACC_INITIAL_BALANCE));
let module = Chain::module_load_v1("piggy_bank_part2.wasm.v1").expect("Module is valid and exists");
let deployment = chain
.module_deploy_v1(Signer::with_one_key(), ACC_ADDR_OWNER, module)
.expect("Deploying valid module should succeed");
let initialization = chain
.contract_init(
Signer::with_one_key(),
ACC_ADDR_OWNER,
Energy::from(10000),
InitContractPayload {
amount: Amount::zero(),
mod_ref: deployment.module_reference,
init_name: OwnedContractName::new_unchecked("init_PiggyBank".to_string()),
param: OwnedParameter::empty(),
},
)
.expect("Initialization should always succeed");
(chain, initialization)
}
#[test]
fn test_init() {
let (chain, initialization) = setup_chain_and_contract();
assert_eq!(
chain.contract_balance(initialization.contract_address),
Some(Amount::zero()),
"Piggy bank is not initialized with balance of zero"
);
}
Run the test to check that it compiles and succeeds.
$cargo concordium test --out piggy_bank_part2.wasm.v1
Note that the command recompiles the Wasm module and replaces the old module in the same location due to the --out
parameter.
Test inserting CCD into a piggy bank#
Next, you should test the different functions for interacting with a piggy bank.
You will start by testing the insert
entrypoint on an intact piggy bank contract.
Create a new test case named test_insert_intact
, and use the helper method create_chain_and_contract
from the previous section to get a chain with two accounts and an initialized piggy bank contract.
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_insert_intact() {
let (mut chain, initialization) = setup_chain_and_contract();
}
Note that you must mark the chain
variable as mutable, since contract updates mutate it.
Now, you are ready to update the contract with the contract_update
method, which has parameters similar to contract_init
:
A
Signer
to sign the transaction.An
invoker
of typeAccountAddress
, which pays for the transaction.An
sender
of typeAddress
, which can either be anAccountAddress
or aContractAddress
.The main utility of the parameter is that it allows you to test internal calls in your contracts directly.
For example, if you have a more complex scenario where an account calls contract
A
which internally calls contractB
.In this case you can test the complete integration by calling
A
.But you can also test
B
as its own unit by calling it directly and specifyingA
as thesender
.
A maximum
Energy
that the contract update can use.A
ContractAddress
, which you get from the initialization variable.An
OwnedReceiveName
, that specifies which receive name in the module you want to initialize.A “receive name” is the contract name concatenated with the entrypoint name and a dot in between.
In this example, the contract
my_contract
and the entrypointmy_entrypoint
combine to the receive namemy_contract.my_entrypoint
.You construct it with either
OwnedReceiveName::new
, which checks the format and returns aResult
, orOwnedReceiveName::new_unchecked
, which performs no checks.
An
OwnedParameter
, which is a wrapper over a byte array that you construct with one of the following methods:OwnedParameter::from_serial
, which serializes the input and checks that the parameter size is valid,TryFrom::<Vec<u8>>::try_from(..)
, which also checks the parameter size,or
OwnedParameter::empty
, which always succeeds.
An
Amount
to send to the contract.
Define the variable insert_amount
with the amount of CCD you want to send to the contract.
Then update the contract with the insert
receive function and pass in insert_amount
:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_insert_intact() {
let (mut chain, initialization) = setup_chain_and_contract();
let insert_amount = Amount::from_ccd(10);
let update = chain
.contract_update(
Signer::with_one_key(),
ACC_ADDR_OWNER,
Address::Account(ACC_ADDR_OWNER),
Energy::from(10000),
UpdateContractPayload {
amount: insert_amount,
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.insert".to_string()),
message: OwnedParameter::empty(),
},
);
}
You can then verify the success of the update and the contract balance:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_insert_intact() {
// .. Lines omitted for brevity.
assert!(update.is_ok(), "Inserting into intact piggy bank failed");
assert_eq!(
chain.contract_balance(initialization.contract_address),
Some(insert_amount),
"Piggy bank balance does not match amount inserted"
);
}
One test that is tempting to add is to check that the piggy bank remains intact
after inserting CCD into it.
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:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_insert_intact() {
let (mut chain, initialization) = setup_chain_and_contract();
let insert_amount = Amount::from_ccd(10);
let update = chain
.contract_update(
Signer::with_one_key(),
ACC_ADDR_OWNER,
Address::Account(ACC_ADDR_OWNER),
Energy::from(10000),
UpdateContractPayload {
amount: insert_amount,
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.insert".to_string()),
message: OwnedParameter::empty(),
},
);
assert!(update.is_ok(), "Inserting into intact piggy bank failed");
assert_eq!(
chain.contract_balance(initialization.contract_address),
Some(insert_amount),
"Piggy bank balance does not match amount inserted"
);
}
Again, verify that everything compiles and the tests succeed using cargo concordium test --out piggy_bank_part2.wasm.v1
.
Test smashing a piggy bank#
Testing smash
will follow the same pattern, but this time you will also use the contract_invoke
method to invoke the view
receive function and check whether the state is smashed.
Start by creating a new test case, test_smash_intact
, setup the chain and contract with the helper function, and update the contract by calling the smash
entrypoint.
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_smash_intact(){
let (mut chain, initialization) = setup_chain_and_contract();
let update = chain
.contract_update(
Signer::with_one_key(),
ACC_ADDR_OWNER,
Address::Account(ACC_ADDR_OWNER),
Energy::from(10000),
UpdateContractPayload {
amount: Amount::zero(),
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.smash".to_string()),
message: OwnedParameter::empty(),
},
)
.expect("Owner is allowed to smash intact piggy bank");
}
Note that you must use the ACC_ADDR_OWNER
for the sender
argument, since it is only the owner who can smash the piggy bank.
To check the PiggyBankState
, you must invoke the view
with the contract_invoke
method.
You could also use contract_update
for this purpose, as you are just interested in the return value, but the benefit of contract_invoke
is that it isn’t a transaction.
So it does not charge the account for calling it, and it does not save changes to contracts.
For seasoned Rust programmers, that is easily seen by its function signature, which takes an immutable reference to the chain (&self
), as opposed to the mutable reference (&mut self
) used in the update method.
Also note that the Signer
parameter is not needed for the invoke method, as the signer is only needed for transactions.
Invoke the view
function below the update:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_smash_intact(){
// .. Lines omitted for brevity.
let invoke = chain
.contract_invoke(
ACC_ADDR_OWNER,
Address::Account(ACC_ADDR_OWNER),
Energy::from(10000),
UpdateContractPayload {
amount: Amount::zero(),
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.view".to_string()),
message: OwnedParameter::empty(),
},
)
.expect("Invoking `view` should always succeed");
}
The next step is to use the return value from (invoke.return_value
), which is a byte array representing a tuple of PiggyBankState
and Amount
.
While it is possible to make assertions about the bytes directly, it is preferable to deserialize the bytes into structured Rust types.
There is a helper method from_bytes
for this exact purpose:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_smash_intact(){
// .. Lines omitted for brevity.
let (state, balance): (PiggyBankState, Amount) =
from_bytes(&invoke.return_value).expect("View should always return a valid result");
Since deserialization might fail, from_bytes
returns a Result
that is unwrapped here.
If you run the tests at this point, the compiler will complain about PiggyBankState
being undeclared.
This is because types and functions in Rust are private by default.
To make the PiggyBankState
public, edit the lib.rs
file and add the pub
keyword.
pub enum PiggyBankState {
Intact,
Smashed,
}
Important
The change you just made does not affect the functionality of the contract, but when you make changes that do, it is essential to build the Wasm module again.
Otherwise, you will continue using the old Wasm module.
Recompiling the module occurs automatically when using cargo concordium test --out piggy_bank_part2.wasm.v1
and the command overwrites the old module in the same location due to the --out
parameter.
With the state
and balance
available, you can make assertions.
The contract should be smashed and have a balance of zero:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_smash_intact(){
// .. Lines omitted for brevity.
assert_eq!(state, PiggyBankState::Smashed, "Piggy bank is not smashed");
assert_eq!(balance, Amount::zero(), "Piggy bank has non-zero balance after being smashed");
}
You can also check that the contract transferred all of its funds to the owner during the update
.
The helper method account_transfers
returns an iterator over (ContractAddress, Amount, AccountAddress)
, representing transfers from contracts to accounts.
Use the collect
method to turn the iterator into a Vec
to compare it.
Since you did not insert any CCD in this test case, the piggy bank should have made a transfer of zero CCD:
// .. Imports, constants, and other functions omitted for brevity.
#[test]
fn test_smash_intact(){
// .. Lines omitted for brevity.
assert_eq!(
update.account_transfers().collect::<Vec<_>>(),
[(
initialization.contract_address,
Amount::zero(),
ACC_ADDR_OWNER
)],
"The piggy bank made incorrect transfers when smashed"
);
}
The complete third test thus becomes:
#[test]
fn test_smash_intact() {
let (mut chain, initialization) = setup_chain_and_contract();
let update = chain
.contract_update(
Signer::with_one_key(),
ACC_ADDR_OWNER,
Address::Account(ACC_ADDR_OWNER),
Energy::from(10000),
UpdateContractPayload {
amount: Amount::zero(),
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.smash".to_string()),
message: OwnedParameter::empty(),
},
)
.expect("Owner is allowed to smash intact piggy bank");
let invoke = chain
.contract_invoke(
ACC_ADDR_OWNER,
Address::Account(ACC_ADDR_OWNER),
Energy::from(10000),
UpdateContractPayload {
amount: Amount::zero(),
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.view".to_string()),
message: OwnedParameter::empty(),
},
)
.expect("Invoking `view` should always succeed");
let (state, balance): (PiggyBankState, Amount) =
from_bytes(&invoke.return_value).expect("View should always return a valid result");
assert_eq!(state, PiggyBankState::Smashed, "Piggy bank is not smashed");
assert_eq!(balance, Amount::zero(), "Piggy bank has non-zero balance after being smashed");
assert_eq!(
update.account_transfers().collect::<Vec<_>>(),
[(
initialization.contract_address,
Amount::zero(),
ACC_ADDR_OWNER
)],
"The piggy bank made incorrect transfers when smashed"
);
}
Ensure everything compiles and the test succeeds using cargo concordium test --out piggy_bank_part2.wasm.v1
.
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:
Setup the chain and contract.
Call
piggy_smash
with theACC_ADDR_OTHER
account.Check that the result is an error with
expect_err
.
The test could look like this:
#[test]
fn test_smash_intact_not_owner() {
let (mut chain, initialization) = setup_chain_and_contract();
chain
.contract_update(
Signer::with_one_key(),
ACC_ADDR_OTHER,
Address::Account(ACC_ADDR_OTHER),
Energy::from(10000),
UpdateContractPayload {
amount: Amount::zero(),
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.smash".to_string()),
message: OwnedParameter::empty(),
},
)
.expect_err("Smashing should only succeed for the owner");
}
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 public SmashError
enum to represent the different
reasons for rejection:
#[derive(Debug, PartialEq, Eq, Serialize, Reject)]
pub 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(
ctx: &ReceiveContext,
host: &mut Host<PiggyBankState>,
) -> 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(
ctx: &ReceiveContext,
host: &mut Host<PiggyBankState>,
) -> 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.
When updates and invokes fail, they return a ContractInvokeError
struct, which has information about the transaction fee, energy usage, and also the reason why a contract call failed.
Some of the reasons include running out of energy, calling a contract that doesn’t exist, etc., but only one variant, which is when the contract rejects on its own, contains the bytes returned by the contract.
The helper method return_value
tries to extract the bytes from the contract rejection and returns an Option<Vec<u8>>
.
With the bytes available, you can use the from_bytes
method and assert why the update failed:
#[test]
fn test_smash_intact_not_owner() {
// .. Lines omitted for brevity.
let return_value = update_err
.return_value()
.expect("Contract should reject and thus return bytes");
let error: SmashError = from_bytes(&return_value)
.expect("Contract should return a `SmashError` in serialized form");
assert_eq!(
error,
SmashError::NotOwner,
"Contract did not fail due to a NotOwner error"
);
}
Finally, you can also check whether the ACC_ADDR_OTHER
account was charged correctly for the transaction.
Use the method account_balance_available
and check that it has the original balance minus the transaction fee for the update transaction.
#[test]
fn test_smash_intact_not_owner() {
// .. Lines omitted for brevity.
assert_eq!(
chain.account_balance_available(ACC_ADDR_OTHER),
Some(ACC_INITIAL_BALANCE - update_err.transaction_fee),
"The invoker account was incorrectly charged"
)
}
Note that account_balance_available
returns an Option<Amount>
as the queried account might not exist.
The final test thus becomes:
#[test]
fn test_smash_intact_not_owner() {
let (mut chain, initialization) = setup_chain_and_contract();
let update_err = chain
.contract_update(
Signer::with_one_key(),
ACC_ADDR_OTHER,
Address::Account(ACC_ADDR_OTHER),
Energy::from(10000),
UpdateContractPayload {
amount: Amount::zero(),
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("PiggyBank.smash".to_string()),
message: OwnedParameter::empty(),
},
)
.expect_err("Smashing should only succeed for the owner");
let return_value = update_err
.return_value()
.expect("Contract should reject and thus return bytes");
let error: SmashError = from_bytes(&return_value)
.expect("Contract should return a `SmashError` in serialized form");
assert_eq!(
error,
SmashError::NotOwner,
"Contract did not fail due to a NotOwner error"
);
assert_eq!(
chain.account_balance_available(ACC_ADDR_OTHER),
Some(ACC_INITIAL_BALANCE - update_err.transaction_fee),
"The invoker account was incorrectly charged"
)
}
This concludes the testing part of this tutorial.
But if you are eager to test all scenarios for the piggy bank, you can try to write the following extra tests using the techniques you just learned:
Test that inserting into a piggy bank with state
Smashed
results in an error.Test that smashing a piggy bank with state
Smashed
results in anAlreadySmashed
error.Test that the
ACC_ADDR_OWNER
account pays for all transaction fees and amounts inserted in a given test.In the
test_insert_intact
test case, this means that transaction fees for deploying, initializing, and updating the contract, plus the amount inserted.To test the deployment cost, the
setup_chain_and_contract
function method must return some additional data. Can you figure out which?
See also
For more information on testing, see Integration test a contract in Rust.