⚠ī¸Smart Contract Testing Library

This tutorial will walk you through effectively setting up and conducting tests for your smart contract using the Concordium smart contract testing library.

Step 1: Install the Concordium Smart Contract Testing Library

To begin testing your smart contract, you'll need to set up the Concordium smart contract testing library. This library allows you to simulate part of a blockchain locally, enabling you to create and interact with contracts in a testing environment before deploying them to the testnet. Follow these steps:

  1. Open the "Cargo.toml" file in your project's root directory to add the "concordium-smart-contract-testing" library.

  2. Include the library under [dev-dependencies], which are dependencies only needed during development.

  3. Set the Rust edition for the library to 2021 or later."

Use the following:

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

[dev-dependencies]
concordium-smart-contract-testing = "3.0"

Step 2: Add a Test Module

Since a smart contract module is a regular Rust library, you can test it as you would any library and add integration tests in the tests folder. Follow these steps:

  1. In your project's root directory, create a folder named "tests".

  2. Inside the "tests" folder, add a new file named "tests.rs".

  3. At the beginning of "tests.rs", import the necessary libraries and contracts.

To add the test module, run the following command:

use concordium_smart_contract_testing::*;

Step 3: Implementation

In this section, you will start with the definitions of the constants and then continue with the function implementations. To implement the Test functions, follow these steps:

Define Imports

Import all the data types, structs, and other required elements from the cis2_dynamic_nft smart contract. Use the following code:

use cis2_dynamic_nft::{
    ContractError, ContractTokenAmount, ContractTokenId, ContractTokenMetadataQueryParams,
    MintParam, MintParams, ViewAddressState, ViewState,
};
use concordium_cis2::*;
use concordium_smart_contract_testing::*;
use concordium_std::*;

Define Constants

Define any constants required for testing, such as account addresses and token IDs. For the sake of tests, use the following:

  • Two accounts ALICE & BOB

  • Two tokens TOKEN_0 & TOKEN_2

  • An account balance with 10000 CCDs as ACC_INITIAL_BALANCE

  • A SIGNER to define your constants.

The following code defines these constants:

/// The tests accounts.
const ALICE: AccountAddress = AccountAddress([0; 32]);
const ALICE_ADDR: Address = Address::Account(ALICE);
const BOB: AccountAddress = AccountAddress([1; 32]);
const BOB_ADDR: Address = Address::Account(BOB);

/// Token IDs.
const TOKEN_0: ContractTokenId = TokenIdU8(3);
const TOKEN_2: ContractTokenId = TokenIdU8(5);

/// Initial balance of the accounts.
const ACC_INITIAL_BALANCE: Amount = Amount::from_ccd(10000);

/// A signer with one key.
const SIGNER: Signer = Signer::with_one_key();

Note that you created the account addresses using an array of 32 bytes, which is how account addresses are represented on Concordium.

Set Up Chain and Contract Initialization

Here, you will set up a local chain using the build created in the previous section. The following steps will be carried out:

  • Set up accounts.

  • Deploy your smart contract module.

  • Initialize the contract.

Note that this is an important function and we will re-use it while creating the other test functions.

Use the following code to implement the function to set up the local chain and initialize the contract:

/// Setup chain and contract.
///
/// Also creates the two accounts, Alice and Bob.
///
/// Alice is the owner of the contract.
fn initialize_chain_and_contract() -> (concordium_smart_contract_testing::Chain, ContractAddress) {
    let mut chain = concordium_smart_contract_testing::Chain::new();

    // Create some accounts accounts on the chain.
    chain.create_account(Account::new(ALICE, ACC_INITIAL_BALANCE));
    chain.create_account(Account::new(BOB, ACC_INITIAL_BALANCE));

    // Load and deploy the module.
    let module = module_load_v1("concordium-out/module.wasm.v1").expect("Module exists");
    let deployment = chain.module_deploy_v1(SIGNER, ALICE, module).expect("Deploy valid module");

    // Initialize the contract.
    let init = chain
        .contract_init(SIGNER, ALICE, Energy::from(10000), InitContractPayload {
            amount:    Amount::zero(),
            mod_ref:   deployment.module_reference,
            init_name: OwnedContractName::new_unchecked("init_cis2_dynamic_nft".to_string()),
            param:     OwnedParameter::empty(),
        })
        .expect("Initialize contract");

    (chain, init.contract_address)
}

Minting Helper Function

Define any helper functions required for testing, such as minting tokens. These functions assist in setting up the contract and conducting specific test scenarios.

Use the code below:

/// Helper function that sets up the contract with two types of tokens minted to
/// Alice. She has 400 of `TOKEN_0` and 400 of `TOKEN_2`.
fn initialize_contract_with_alice_tokens(
) -> (concordium_smart_contract_testing::Chain, ContractAddress, ContractInvokeSuccess) {
    let (mut chain, contract_address) = initialize_chain_and_contract();

    let mut test_v1 = Vec::new();
    test_v1.push(MetadataUrl {
        url:  format!("https://some.example/token/1/{TOKEN_0}"),
        hash: None,
    });
    // we will need the following to test the upgrade function.
    test_v1.push(MetadataUrl {
        url:  format!("https://some.example/token/2/{TOKEN_0}"),
        hash: None,
    });

    let mut test_v2 = Vec::new();
    test_v2.push(MetadataUrl {
        url:  format!("https://some.example/token/3/{TOKEN_2}"),
        hash: None,
    });
    let mint_param_1 = MintParam {
        token_amount: 400.into(),
        metadata_url: test_v1,
    };
    let mut tok = collections::BTreeMap::new();

    tok.insert(TOKEN_0, mint_param_1);
    let mint_params = MintParams {
        owner:  ALICE_ADDR,
        tokens: tok,
    };

    let _update = chain
        .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.mint".to_string()),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&mint_params).expect("Mint params"),
        })
        .expect("Mint tokens");

    let mint_param_2 = MintParam {
        token_amount: 400.into(),
        metadata_url: test_v2,
    };
    let mut tok2 = collections::BTreeMap::new();

    tok2.insert(TOKEN_2, mint_param_2);
    let mint_params = MintParams {
        owner:  ALICE_ADDR,
        tokens: tok2,
    };

    let update = chain
        .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.mint".to_string()),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&mint_params).expect("Mint params"),
        })
        .expect("Mint tokens");

    (chain, contract_address, update)
}

Step 4: Write Test Functions

Write individual test functions to verify different aspects of your smart contract's functionality. For example, you could test minting, upgrading, operator actions, and unauthorized sender scenarios.

Each test function should cover a specific aspect of your smart contract and include assertions to verify expected outcomes.

Test Minting

/// Test minting succeeds and the tokens are owned by the given address and
/// the appropriate events are logged. When token is minted first index of
/// the Metadata URL vector will be logged, if there is only one MetadatUrl then
/// it will be logged.
#[test]
fn test_minting() {
    let (chain, contract_address, update) = initialize_contract_with_alice_tokens();

    // Invoke the view entrypoint and check that the tokens are owned by Alice.
    let invoke = chain
        .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.view".to_string()),
            address:      contract_address,
            message:      OwnedParameter::empty(),
        })
        .expect("Invoke view");

    // Check that the tokens are owned by Alice.
    let rv: ViewState = invoke.parse_return_value().expect("ViewState return value");
    assert_eq!(rv.tokens[..], [TOKEN_0, TOKEN_2]);
    assert_eq!(rv.state, vec![(ALICE_ADDR, ViewAddressState {
        balances:  vec![(TOKEN_0, 400.into()), (TOKEN_2, 400.into())],
        operators: Vec::new(),
    })]);

    // Check that the events are logged.
    let events = update.events().flat_map(|(_addr, events)| events);

    let events: Vec<Cis2Event<ContractTokenId, ContractTokenAmount>> =
        events.map(|e| e.parse().expect("Deserialize event")).collect();

    assert_eq!(events, [
        Cis2Event::Mint(MintEvent {
            token_id: TokenIdU8(5),
            amount:   TokenAmountU64(400),
            owner:    ALICE_ADDR,
        }),
        Cis2Event::TokenMetadata(TokenMetadataEvent {
            token_id:     TokenIdU8(5),
            metadata_url: MetadataUrl {
                url:  format!("https://some.example/token/3/{TOKEN_2}").to_string(),
                hash: None,
            },
        }),
    ]);
}

Test Upgrade

/// Test upgrading the Metadata token as the contract owner. Once a token is
/// upgraded, the TokenMetadata event will be emitted and it should contain
/// the next MetadataUrl in the Metadata URLs vector and similarly
/// `tokenMetadata` function should return the last MetadataUrl.
#[test]
fn test_upgrade() {
    let (mut chain, contract_address, _update) = initialize_contract_with_alice_tokens();
    let _update = chain
        .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.upgrade".to_string()),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&TOKEN_0).expect("TokenId"),
        })
        .expect("Upgrade tokens");
    // Check that the events are logged.
    let events = _update.events().flat_map(|(_addr, events)| events);

    let events: Vec<Cis2Event<ContractTokenId, ContractTokenAmount>> =
        events.map(|e| e.parse().expect("Deserialize event")).collect();

    assert_eq!(events, [Cis2Event::TokenMetadata(TokenMetadataEvent {
        token_id:     TokenIdU8(3),
        metadata_url: MetadataUrl {
            url:  format!("https://some.example/token/2/{TOKEN_0}").to_string(),
            hash: None,
        },
    }),]);
    let mut token_param = ContractTokenMetadataQueryParams {
        queries: vec![TOKEN_0],
    };
    token_param.queries.insert(0, TOKEN_0);
    // Invoke the tokenMetadata entrypoint and check what MetadataUrl returns
    let invoke = chain
        .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked(
                "cis2_dynamic_nft.tokenMetadata".to_string(),
            ),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&token_param).expect("tokenMetada params"),
        })
        .expect("Invoke tokenMetadata");

    let rv: TokenMetadataQueryResponse =
        invoke.parse_return_value().expect("tokenMetadata return value");
    let expected_metadata = MetadataUrl {
        url:  format!("https://some.example/token/2/{TOKEN_0}"),
        hash: None,
    };
    assert_eq!(rv.0.get(0), Some(&expected_metadata));
}

Test Operator

/// Test that an operator can make a transfer.
#[test]
fn test_operator_can_transfer() {
    let (mut chain, contract_address, _update) = initialize_contract_with_alice_tokens();

    // Add Bob as an operator for Alice.
    let params = UpdateOperatorParams(vec![UpdateOperator {
        update:   OperatorUpdate::Add,
        operator: BOB_ADDR,
    }]);
    chain
        .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked(
                "cis2_dynamic_nft.updateOperator".to_string(),
            ),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&params).expect("UpdateOperator params"),
        })
        .expect("Update operator");

    // Let Bob make a transfer to himself on behalf of Alice.
    let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer {
        from:     ALICE_ADDR,
        to:       Receiver::Account(BOB),
        token_id: TOKEN_0,
        amount:   TokenAmountU64(1),
        data:     AdditionalData::empty(),
    }]);

    chain
        .contract_update(SIGNER, BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.transfer".to_string()),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&transfer_params).expect("Transfer params"),
        })
        .expect("Transfer tokens");

    // Check that Bob now has 1 of `TOKEN_0` and Alice has 399. Also check that
    // Alice still has 400 `TOKEN_2`.
    let invoke = chain
        .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.view".to_string()),
            address:      contract_address,
            message:      OwnedParameter::empty(),
        })
        .expect("Invoke view");
    let rv: ViewState = invoke.parse_return_value().expect("ViewState return value");
    assert_eq!(rv.state, vec![
        (ALICE_ADDR, ViewAddressState {
            balances:  vec![(TOKEN_0, 399.into()), (TOKEN_2, 400.into())],
            operators: vec![BOB_ADDR],
        }),
        (BOB_ADDR, ViewAddressState {
            balances:  vec![(TOKEN_0, 1.into())],
            operators: Vec::new(),
        }),
    ]);
}

Test Unauthorized Sender

/// Test that a transfer fails when the sender is neither an operator or the
/// owner. In particular, Bob will attempt to transfer some of Alice's tokens to
/// himself.
#[test]
fn test_unauthorized_sender() {
    let (mut chain, contract_address, _update) = initialize_contract_with_alice_tokens();

    // Construct a transfer of `TOKEN_0` from Alice to Bob, which will be submitted
    // by Bob.
    let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer {
        from:     ALICE_ADDR,
        to:       Receiver::Account(BOB),
        token_id: TOKEN_0,
        amount:   TokenAmountU64(1),
        data:     AdditionalData::empty(),
    }]);

    // Notice that Bob is the sender/invoker.
    let update = chain
        .contract_update(SIGNER, BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload {
            amount:       Amount::zero(),
            receive_name: OwnedReceiveName::new_unchecked("cis2_dynamic_nft.transfer".to_string()),
            address:      contract_address,
            message:      OwnedParameter::from_serial(&transfer_params).expect("Transfer params"),
        })
        .expect_err("Transfer tokens");

    // Check that the correct error is returned.
    let rv: ContractError = update.parse_return_value().expect("ContractError return value");
    assert_eq!(rv, ContractError::Unauthorized);
}

Note the following when testing:

  • Tests run against the compiled Wasm module, risking using outdated versions.

  • Use cargo concordium test to both build and test, ensuring the latest code is tested.

  • By default, the compiled Wasm module is in the target/ folder.

  • Specify a custom location with --out <custom_location>/<output_file_name>.wasm for easier testing.

Step 5: Run Tests

Once you've implemented your test functions, you can run the tests to verify the correctness of your smart contract.

Run the tests using the cargo concordium test command:

cargo concordium test --out unit_tests.wasm.v1

Note that the test command only builds your module in Cargo Concordium version 2.9.0 or higher. Also, note that for the highest assurance of correctness, you should deploy the exact module that you tested.

You will see the following when you run the command:

Last updated