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.

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 Setup the development environment 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 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. You should add it under the section [dev-dependencies], which are dependencies only needed during development, as it is only needed during testing. The library requires the Rust edition 2021 or greater, which you must also set:

[package]
# ...
edition = "2021"

[dev-dependencies]
concordium-smart-contract-testing = "3.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 library and your contract at the top of the file.

use concordium_smart_contract_testing::*;
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 = AccountAddress([0u8; 32]);
const ACC_ADDR_OTHER: AccountAddress = AccountAddress([1u8; 32]);
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

You created the account addresses 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, 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 with init_ on the chain to distinguish them from receive functions (entrypoints). You constuct it with either OwnedContractName::new, which checks the validity and returns a Result, or OwnedContractName::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:

  • 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::*;
use piggy_bank_part2::*;

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) = create_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 type AccountAddress, which pays for the transaction.

  • An sender of type Address, which can either be an AccountAddress or a ContractAddress.

    • 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 contract B.

      • 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 specifying A as the sender.

  • 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 entrypoint my_entrypoint combine to the receive name my_contract.my_entrypoint.

    • You construct it with either OwnedReceiveName::new, which checks the format and returns a Result, or OwnedReceiveName::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:

  • 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) = create_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) = create_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) = create_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 the ACC_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 an AlreadySmashed 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.

Was this article helpful?
Legal information